<?php

namespace modules\selfserve\classes;

require_once WD . '/classes/db.php';
require_once WD . '/classes/selfserve_schema_bootstrap.php';
require_once WD . '/modules/selfserve/classes/selfserve_config_versioning.php';
require_once WD . '/modules/selfserve/classes/selfserve_virtual_hardware.php';
require_once WD . '/modules/selfserve/classes/selfserve_wash_flow.php';
require_once WD . '/modules/selfserve/classes/selfserve_studio_actions.php';
require_once WD . '/modules/selfserve/helpers/selfserve_task_gate_type.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_lanes_o.php';
require_once WD . '/objects/selfserve_config_versions_o.php';
require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';

if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_department_workspace_service.php')) {
    require_once WD . '/modules/edgegateway/classes/edge_gateway_department_workspace_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_manager.php')) {
    require_once WD . '/modules/edgegateway/classes/edge_gateway_manager.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_operation_service.php')) {
    require_once WD . '/modules/edgegateway/classes/edge_gateway_operation_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_registry_service.php')) {
    require_once WD . '/modules/edgegateway/classes/edge_gateway_registry_service.php';
}
if (is_file(WD . '/modules/edgegateway/classes/edge_gateway_view_service.php')) {
    require_once WD . '/modules/edgegateway/classes/edge_gateway_view_service.php';
}

use classes\db;
use classes\edge_gateway_department_workspace_service;
use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
use classes\edge_gateway_view_service;
use classes\selfserve_schema_bootstrap;
use modules\selfserve\classes\selfserve_studio_actions;
use modules\selfserve\helpers\selfserve_task_gate_type;
use objects\department_selfserve_tasks_o;
use objects\selfserve_config_versions_o;

class selfserve_studio_graph
{
    private const DEFAULT_PATH_MAX_STATES = 2048;
    private const MAX_PATH_MAX_STATES = 2048;
    private const DEFAULT_PATH_SAMPLE_LIMIT = 2048;
    private const MAX_PATH_SAMPLE_LIMIT = 2048;

    /** @var array<string,array<int,string>> */
    private array $columnCache = [];

    public function __construct()
    {
        selfserve_schema_bootstrap::ensureTables();
    }

    /**
     * @param array<string,bool> $permissions
     * @return array<string,mixed>
     */
    public function buildGraph(int $departmentId, ?int $userId = null, array $permissions = []): array
    {
        $versioning = new selfserve_config_versioning();
        $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
        $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId);
        $gatewayWorkspace = ($permissions['modules_shelly_config'] ?? false)
            ? $this->buildGatewayWorkspace($departmentId)
            : [
                'gateways' => [],
                'relays' => [],
                'lanes' => [],
                'issues' => [],
                'actions' => [],
                'restricted' => true,
            ];
        $lookups = $this->buildLookups($departmentId, $config, $gatewayWorkspace);
        $layout = $this->loadLayout($departmentId, $userId);
        $configWithAttachments = $this->withTaskAttachments($config);
        $graph = $this->buildGraphFromConfig($configWithAttachments, [
            'department_id' => $departmentId,
            'lookups' => $lookups,
            'gateway_workspace' => $gatewayWorkspace,
        ], $layout);

        $validation = $versioning->validateConfig($config);
        $virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace);
        if ($virtualWarnings !== []) {
            $validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings)));
        }
        $validation['items'] = $this->buildValidationItems($validation);

        return [
            'nodes' => $graph['nodes'],
            'edges' => $graph['edges'],
            'lookups' => $lookups,
            'validation' => $validation,
            'layout' => $layout,
            'versions' => $versioning->listVersions($departmentId),
            'active_config' => $versioning->getPublishedConfig($departmentId),
            'draft' => [
                'id' => $draft['id'] ?? null,
                'status' => $draft['status'] ?? selfserve_config_versioning::STATUS_DRAFT,
                'version_number' => $draft['version_number'] ?? null,
                'created_at' => $draft['created_at'] ?? null,
                'updated_at' => $draft['updated_at'] ?? null,
            ],
            'simulator_defaults' => $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace),
            'gateway_workspace' => $gatewayWorkspace,
            'permissions' => $permissions,
            'meta' => [
                'department_id' => $departmentId,
                'layout_affects_runtime' => false,
                'generated_at' => date('c'),
                'path_editor' => is_array($config['v2_meta']['path_editor'] ?? null) ? (array)$config['v2_meta']['path_editor'] : ['paths' => []],
            ],
        ];
    }

    /**
     * Pure graph builder used by unit tests and the API serializer.
     *
     * @param array<string,mixed> $config
     * @param array<string,mixed> $context
     * @param array<string,mixed> $layout
     * @return array{nodes:array<int,array<string,mixed>>,edges:array<int,array<string,mixed>>}
     */
    public function buildGraphFromConfig(array $config, array $context = [], array $layout = []): array
    {
        $lookups = is_array($context['lookups'] ?? null) ? (array)$context['lookups'] : [];
        $gatewayWorkspace = is_array($context['gateway_workspace'] ?? null) ? (array)$context['gateway_workspace'] : [];
        $isV2Config = (int)($config['schema_version'] ?? 0) === selfserve_config_versioning::SCHEMA_VERSION_V2;
        $nodes = [];
        $edges = [];

        $nodes[] = $this->node('checkpoint:start', 'input', 'Runtime start', 'runtime_checkpoint', [
            'stage' => 'start',
            'subtitle' => 'Vehicle scanned',
        ], 0, 0);
        $nodes[] = $this->node('checkpoint:eligible', 'default', 'Eligibility resolved', 'runtime_checkpoint', [
            'stage' => 'eligible',
            'subtitle' => 'Questions, rules, and gates evaluated',
        ], 320, 0);
        $nodes[] = $this->node('checkpoint:finish', 'output', 'Wash complete', 'runtime_checkpoint', [
            'stage' => 'finish',
            'subtitle' => 'Session closed',
        ], 640, 0);
        $edges[] = $this->edge('runtime:start-eligible', 'checkpoint:start', 'checkpoint:eligible', 'runtime', 'runtime');
        $edges[] = $this->edge('runtime:eligible-finish', 'checkpoint:eligible', 'checkpoint:finish', 'runtime', 'runtime');

        foreach ($this->lookupRows($lookups, 'lanes') as $index => $lane) {
            $id = 'lane:' . (int)($lane['id'] ?? 0);
            $nodes[] = $this->node($id, 'default', (string)($lane['label'] ?? ('Lane ' . ($lane['id'] ?? ''))), 'lane', [
                'object_id' => (int)($lane['id'] ?? 0),
                'raw' => $lane,
                'subtitle' => 'Lane scope',
            ], 0, 180 + ($index * 120));
        }

        foreach ($this->lookupRows($lookups, 'machine_types') as $index => $machineType) {
            $id = 'machine_type:' . (int)($machineType['id'] ?? 0);
            $nodes[] = $this->node($id, 'default', (string)($machineType['label'] ?? ('Machine type ' . ($machineType['id'] ?? ''))), 'machine_type', [
                'object_id' => (int)($machineType['id'] ?? 0),
                'raw' => $machineType,
                'subtitle' => 'Reusable machine setup',
            ], 0, 560 + ($index * 120));
        }

        foreach ($this->lookupRows($lookups, 'vehicle_types') as $index => $vehicleType) {
            $id = 'vehicle_type:' . (int)($vehicleType['id'] ?? 0);
            $nodes[] = $this->node($id, 'default', (string)($vehicleType['label'] ?? ('Vehicle type ' . ($vehicleType['id'] ?? ''))), 'vehicle_type', [
                'object_id' => (int)($vehicleType['id'] ?? 0),
                'raw' => $vehicleType,
                'subtitle' => 'Vehicle scope',
            ], 0, 880 + ($index * 120));
        }

        foreach ($this->sortedRows((array)($config['conditions'] ?? []), ['name', 'id']) as $index => $condition) {
            $id = (int)($condition['id'] ?? 0);
            $nodes[] = $this->node('condition:' . $id, 'default', $this->entityLabel('condition', $id, $condition, $lookups), 'condition', [
                'object_id' => $id,
                'raw' => $condition,
                'scope' => $this->scopeForRow($condition, $lookups),
                'expression_summary' => $this->expressionSummary(is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : []),
                'subtitle' => $this->scopeLabel($condition, $lookups),
            ], 360, 160 + ($index * 130));

            $parentId = $this->nullableInt($condition['condition_id'] ?? null);
            if ($parentId !== null) {
                $edges[] = $this->edge('condition-parent:' . $parentId . ':' . $id, 'condition:' . $parentId, 'condition:' . $id, 'condition_group', 'parent');
            }
            if ($isV2Config && is_array($condition['expression'] ?? null)) {
                $this->appendExpressionEdges($edges, 'condition:' . $id, $id, (array)$condition['expression']);
            }
            $this->appendScopeEdges($edges, 'condition:' . $id, $condition);
        }

        foreach ($this->sortedRows((array)($config['questions'] ?? []), ['order_priority', 'id']) as $index => $question) {
            $id = (int)($question['id'] ?? 0);
            $nodes[] = $this->node('question:' . $id, 'default', $this->entityLabel('question', $id, $question, $lookups), 'question', [
                'object_id' => $id,
                'raw' => $question,
                'scope' => $this->scopeForRow($question, $lookups),
                'subtitle' => $this->scopeLabel($question, $lookups),
            ], 720, 160 + ($index * 130));

            $conditionId = $this->nullableInt($question['condition_id'] ?? null);
            if ($conditionId !== null) {
                $edges[] = $this->edge('question-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'question:' . $id, 'visibility_gate', 'show if');
            }
            $this->appendScopeEdges($edges, 'question:' . $id, $question);
        }

        if (!$isV2Config) {
            foreach ($this->sortedRows((array)($config['rules'] ?? []), ['condition_id', 'id']) as $index => $rule) {
                $id = (int)($rule['id'] ?? 0);
                $nodes[] = $this->node('rule:' . $id, 'default', $this->entityLabel('rule', $id, $rule, $lookups), 'rule', [
                    'object_id' => $id,
                    'raw' => $rule,
                    'subtitle' => $this->ruleSubtitle($rule, $lookups),
                ], 520, 520 + ($index * 120));

                $conditionId = (int)($rule['condition_id'] ?? 0);
                if ($conditionId > 0) {
                    $edges[] = $this->edge('rule-owner:' . $conditionId . ':' . $id, 'rule:' . $id, 'condition:' . $conditionId, 'condition_rule', 'rule of');
                }
                $objectType = strtolower((string)($rule['object_type'] ?? ''));
                $objectId = (int)($rule['object_id'] ?? 0);
                if (in_array($objectType, ['question', 'condition', 'task'], true) && $objectId > 0) {
                    $edges[] = $this->edge('rule-input:' . $objectType . ':' . $objectId . ':' . $id, $objectType . ':' . $objectId, 'rule:' . $id, 'rule_input', (string)($rule['type'] ?? 'rule'));
                }
            }
        }

        $tasksByScope = [];
        $taskRows = $this->sortedRows((array)($config['tasks'] ?? []), ['order_priority', 'id']);
        foreach ($taskRows as $index => $task) {
            $id = (int)($task['id'] ?? 0);
            $nodes[] = $this->node('task:' . $id, 'default', $this->entityLabel('task', $id, $task, $lookups), 'task', [
                'object_id' => $id,
                'raw' => $this->normalizeTaskPayload($task),
                'scope' => $this->scopeForRow($task, $lookups),
                'subtitle' => $this->scopeLabel($task, $lookups),
            ], 1080, 160 + ($index * 130));

            $gateType = strtoupper((string)($task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
            $gateRefId = $this->nullableInt($task['gate_ref_id'] ?? null);
            if ($gateType === selfserve_task_gate_type::CONDITION->value && $gateRefId !== null) {
                $edges[] = $this->edge('task-gate:condition:' . $gateRefId . ':' . $id, 'condition:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks');
            } elseif ($gateType === selfserve_task_gate_type::QUESTION->value && $gateRefId !== null) {
                $edges[] = $this->edge('task-gate:question:' . $gateRefId . ':' . $id, 'question:' . $gateRefId, 'task:' . $id, 'task_gate', 'unlocks');
            }

            $scopeKey = implode(':', [
                (int)($task['department'] ?? 0),
                (int)($task['lane'] ?? 0),
                (int)($task['product'] ?? 0),
                (int)($task['machine_type_id'] ?? 0),
            ]);
            $tasksByScope[$scopeKey][] = $task;
            $this->appendScopeEdges($edges, 'task:' . $id, $task);
        }

        foreach ($tasksByScope as $tasks) {
            $orderedTasks = $this->sortedRows($tasks, ['order_priority', 'id']);
            for ($i = 1; $i < count($orderedTasks); $i++) {
                $sourceId = (int)($orderedTasks[$i - 1]['id'] ?? 0);
                $targetId = (int)($orderedTasks[$i]['id'] ?? 0);
                if ($sourceId > 0 && $targetId > 0) {
                    $edges[] = $this->edge('task-order:' . $sourceId . ':' . $targetId, 'task:' . $sourceId, 'task:' . $targetId, 'task_order', 'then');
                }
            }
        }

        $actionsByEvent = [];
        $actionRows = $this->sortedRows((array)($config['actions'] ?? []), ['event', 'order_priority', 'id']);
        foreach ($actionRows as $index => $action) {
            $normalizedAction = selfserve_studio_actions::normalize($action);
            $id = (int)($normalizedAction['id'] ?? 0);
            if ($id <= 0) {
                continue;
            }
            $actionsByEvent[$normalizedAction['event']][] = $normalizedAction;
            $nodes[] = $this->node('action:' . $id, 'default', $this->entityLabel('action', $id, $normalizedAction, $lookups), 'action', [
                'object_id' => $id,
                'raw' => $normalizedAction,
                'scope' => $this->scopeForRow($normalizedAction, $lookups),
                'subtitle' => $this->actionSubtitle($normalizedAction, $lookups),
                'action_label' => selfserve_studio_actions::operationLabel((string)$normalizedAction['operation'], $normalizedAction['relay_state']),
                'event_label' => selfserve_studio_actions::eventLabel((string)$normalizedAction['event']),
                'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']),
            ], 1360, 160 + ($index * 130));

            $eventSource = match ((string)$normalizedAction['event']) {
                selfserve_studio_actions::EVENT_WASH_START_COMMAND => 'checkpoint:start',
                selfserve_studio_actions::EVENT_WASH_STOP_COMMAND => 'checkpoint:finish',
                selfserve_studio_actions::EVENT_MACHINE_START_TRIGGERED => 'checkpoint:eligible',
                default => 'checkpoint:start',
            };
            $edges[] = $this->edge('action-event:' . $normalizedAction['event'] . ':' . $id, $eventSource, 'action:' . $id, 'action_event', $normalizedAction['wash_mode']);

            $conditionId = $this->nullableInt($normalizedAction['condition_id'] ?? null);
            if ($conditionId !== null) {
                $edges[] = $this->edge('action-gate:' . $conditionId . ':' . $id, 'condition:' . $conditionId, 'action:' . $id, 'action_gate', 'allows');
            }
            $this->appendScopeEdges($edges, 'action:' . $id, $normalizedAction);
        }

        foreach ($actionsByEvent as $event => $actions) {
            $orderedActions = $this->sortedRows($actions, ['order_priority', 'id']);
            for ($i = 1; $i < count($orderedActions); $i++) {
                $sourceId = (int)($orderedActions[$i - 1]['id'] ?? 0);
                $targetId = (int)($orderedActions[$i]['id'] ?? 0);
                if ($sourceId > 0 && $targetId > 0) {
                    $edges[] = $this->edge('action-order:' . $event . ':' . $sourceId . ':' . $targetId, 'action:' . $sourceId, 'action:' . $targetId, 'action_order', 'then');
                }
            }
        }

        $this->appendGatewayNodesAndEdges($nodes, $edges, $gatewayWorkspace);
        $this->appendTaskServiceEdges($edges, $taskRows, $gatewayWorkspace);
        $this->appendActionRelayEdges($edges, $actionRows, $gatewayWorkspace);

        return [
            'nodes' => $this->applyLayoutToNodes($nodes, $layout),
            'edges' => array_values($edges),
        ];
    }

    /**
     * @param array<string,mixed> $payload
     * @param array<string,bool> $permissions
     * @return array<string,mixed>
     */
    public function applyGraphSave(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array
    {
        $operations = isset($payload['operations']) && is_array($payload['operations']) ? (array)$payload['operations'] : [];

        $versioning = new selfserve_config_versioning();
        $draft = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
        $config = is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId);

        if ($versioning->isV2Config($config)) {
            foreach ($operations as $operation) {
                if (is_array($operation)) {
                    $this->applyConfigOperation($departmentId, $config, $operation, $permissions);
                }
            }

            $validation = $versioning->validateConfig($config);
            $draftObject = (new selfserve_config_versions_o())->select((int)($draft['id'] ?? 0));
            if (!$draftObject->exists()) {
                throw new \RuntimeException('Self-serve draft version was not found.');
            }
            $draftObject->config_json->set($config);
            $draftObject->validation_result_json->set($validation);
        } else {
            foreach ($operations as $operation) {
                if (is_array($operation)) {
                    $this->applyOperation($departmentId, $operation, $permissions);
                }
            }
        }

        if (isset($payload['layout']) && is_array($payload['layout'])) {
            $this->saveLayout($departmentId, $userId, (array)$payload['layout']);
        } elseif (isset($payload['nodes']) && is_array($payload['nodes'])) {
            $this->saveLayout($departmentId, $userId, [
                'nodes' => $this->extractNodePositions((array)$payload['nodes']),
                'viewport' => is_array($payload['viewport'] ?? null) ? (array)$payload['viewport'] : [],
            ]);
        }

        if (!$versioning->isV2Config($config)) {
            $versioning->syncDraftFromLegacyForDepartment($departmentId);
        }
        return $this->buildGraph($departmentId, $userId, $permissions);
    }

    /**
     * @param array<string,mixed> $layout
     * @return array<string,mixed>
     */
    public function saveLayout(int $departmentId, ?int $userId, array $layout): array
    {
        $normalized = [
            'nodes' => $this->extractNodePositions((array)($layout['nodes'] ?? [])),
            'viewport' => is_array($layout['viewport'] ?? null) ? (array)$layout['viewport'] : [],
            'saved_at' => date('c'),
            'runtime_affecting' => false,
        ];
        $layoutJson = json_encode($normalized, JSON_UNESCAPED_UNICODE);
        if ($layoutJson === false) {
            throw new \RuntimeException('Failed to encode studio layout JSON: ' . json_last_error_msg());
        }

        $pdo = db::getPDO();
        $statement = $pdo->prepare(
            "SELECT id
             FROM department_selfserve_studio_layouts
             WHERE department_id = :department_id
               AND " . ($userId === null ? "user_id IS NULL" : "user_id = :user_id") . "
               AND deleted_at IS NULL
             ORDER BY id DESC
             LIMIT 1"
        );
        $params = [':department_id' => $departmentId];
        if ($userId !== null) {
            $params[':user_id'] = $userId;
        }
        $statement->execute($params);
        $row = $statement->fetch(\PDO::FETCH_ASSOC);

        if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
            $update = $pdo->prepare(
                "UPDATE department_selfserve_studio_layouts
                 SET layout_json = :layout_json, updated_at = NOW()
                 WHERE id = :id"
            );
            $update->execute([
                ':layout_json' => $layoutJson,
                ':id' => (int)$row['id'],
            ]);
        } else {
            $insert = $pdo->prepare(
                "INSERT INTO department_selfserve_studio_layouts (department_id, user_id, layout_json)
                 VALUES (:department_id, :user_id, :layout_json)"
            );
            $insert->execute([
                ':department_id' => $departmentId,
                ':user_id' => $userId,
                ':layout_json' => $layoutJson,
            ]);
        }

        return $normalized;
    }

    /**
     * @return array<string,mixed>
     */
    public function loadLayout(int $departmentId, ?int $userId): array
    {
        $pdo = db::getPDO();
        $statement = $pdo->prepare(
            "SELECT layout_json
             FROM department_selfserve_studio_layouts
             WHERE department_id = :department_id
               AND deleted_at IS NULL
               AND (user_id = :user_id_filter OR user_id IS NULL)
             ORDER BY CASE WHEN user_id = :user_id_sort THEN 0 ELSE 1 END, updated_at DESC, id DESC
             LIMIT 1"
        );
        $statement->execute([
            ':department_id' => $departmentId,
            ':user_id_filter' => $userId,
            ':user_id_sort' => $userId,
        ]);
        $row = $statement->fetch(\PDO::FETCH_ASSOC);
        if (!is_array($row)) {
            return [
                'nodes' => [],
                'viewport' => [],
                'runtime_affecting' => false,
            ];
        }

        $layout = json_decode((string)($row['layout_json'] ?? '{}'), true);
        if (!is_array($layout)) {
            $layout = [];
        }
        $layout['runtime_affecting'] = false;
        return $layout;
    }

    /**
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    public function validatePayload(int $departmentId, array $payload = []): array
    {
        $versioning = new selfserve_config_versioning();
        $draft = $versioning->ensureDraftFromLegacy($departmentId, null, false);
        $config = is_array($payload['config'] ?? null)
            ? (array)$payload['config']
            : (is_array($draft['config'] ?? null) ? (array)$draft['config'] : $versioning->snapshotLegacyConfig($departmentId));
        if (isset($payload['operations']) && is_array($payload['operations']) && $versioning->isV2Config($config)) {
            foreach ((array)$payload['operations'] as $operation) {
                if (is_array($operation)) {
                    $this->applyConfigOperation($departmentId, $config, $operation);
                }
            }
        }
        $validation = $versioning->validateConfig($config);
        $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId);
        $virtualWarnings = (new selfserve_virtual_hardware())->validationWarnings($gatewayWorkspace);
        if ($virtualWarnings !== []) {
            $validation['warnings'] = array_values(array_unique(array_merge((array)($validation['warnings'] ?? []), $virtualWarnings)));
        }
        $validation['items'] = $this->buildValidationItems($validation);
        return $validation;
    }

    /**
     * @param array<string,mixed> $payload
     * @param array<string,bool> $permissions
     * @return array<string,mixed>
     */
    public function simulateGraph(int $departmentId, array $payload, ?int $userId = null, array $permissions = []): array
    {
        $laneId = (int)($payload['lane_id'] ?? 0);
        if ($laneId <= 0) {
            throw new \RuntimeException('lane_id is required for studio simulation.');
        }

        $versioning = new selfserve_config_versioning();
        $configContext = $this->loadSimulationConfig($departmentId, $payload, $permissions, $versioning);
        $configSource = $configContext['config_source'];
        $config = $configContext['config'];
        $versionId = $configContext['version_id'];

        $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
        $includeHardware = $includeHardware !== false;
        $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? '')));
        if ($hardwareMode === '') {
            $hardwareMode = $includeHardware ? 'studio' : 'none';
        }
        if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) {
            $hardwareMode = 'studio';
        }
        if ($hardwareMode === 'none') {
            $includeHardware = false;
        }
        if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) {
            $gatewayWorkspace = [
                'gateways' => [],
                'relays' => [],
                'lanes' => [],
                'issues' => [],
                'actions' => [],
                'restricted' => !$includeHardware ? false : true,
                'virtual' => [
                    'enabled' => $hardwareMode === 'studio',
                    'has_virtual_hardware' => false,
                    'gateway_count' => 0,
                    'binding_count' => 0,
                ],
            ];
        } else {
            $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real');
        }
        $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId);
        $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace);
        $graph = $this->buildGraphFromConfig($graphConfig, [
            'department_id' => $departmentId,
            'lookups' => $lookups,
            'gateway_workspace' => $gatewayWorkspace,
        ]);

        return (new selfserve_wash_flow())->previewStudioSimulation(
            $departmentId,
            $laneId,
            (string)($payload['reg'] ?? ''),
            array_key_exists('customer_number', $payload) ? $this->nullableInt($payload['customer_number']) : null,
            array_key_exists('vehicle_type_id', $payload) ? $this->nullableInt($payload['vehicle_type_id']) : null,
            [
                'mode' => 'full_dry_run',
                'config_source' => $configSource,
                'config_payload' => $config,
                'config_version_id' => $versionId,
                'answer_overrides' => is_array($payload['answer_overrides'] ?? null) ? (array)$payload['answer_overrides'] : [],
                'include_hardware' => $includeHardware,
                'hardware_mode' => $hardwareMode,
                'lookups' => $lookups,
                'gateway_workspace' => $gatewayWorkspace,
                'graph' => $graph,
            ],
        );
    }

    /**
     * @param array<string,mixed> $payload
     * @param array<string,bool> $permissions
     * @return array{config_source:string,config:array<string,mixed>|null,version_id:int|null}
     */
    private function loadSimulationConfig(
        int $departmentId,
        array $payload,
        array $permissions,
        selfserve_config_versioning $versioning
    ): array {
        $configSource = $this->resolveSimulationConfigSource($payload, $permissions);

        if ($configSource === 'published') {
            $version = $versioning->getPublishedV2Config($departmentId);
            return [
                'config_source' => $configSource,
                'config' => is_array($version['config'] ?? null) ? (array)$version['config'] : null,
                'version_id' => isset($version['version_id']) ? (int)$version['version_id'] : null,
            ];
        }

        $draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus(
            $departmentId,
            selfserve_config_versioning::STATUS_DRAFT
        );
        if (!$draft->exists()) {
            return [
                'config_source' => $configSource,
                'config' => $versioning->snapshotLegacyConfig($departmentId),
                'version_id' => null,
            ];
        }

        $config = (array)($draft->config_json->value() ?? []);
        if (!$versioning->isV2Config($config)) {
            $config = $versioning->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
        }

        return [
            'config_source' => $configSource,
            'config' => $config,
            'version_id' => (int)$draft->id,
        ];
    }

    /**
     * @param array<string,mixed> $payload
     * @param array<string,bool> $permissions
     */
    private function resolveSimulationConfigSource(array $payload, array $permissions): string
    {
        $configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
        if (!in_array($configSource, ['draft', 'published'], true)) {
            $configSource = 'draft';
        }

        if ($configSource === 'draft' && !($permissions['can_view'] ?? false)) {
            throw new \RuntimeException('Draft studio simulation requires list_department_selfserve_config_versions permission.');
        }

        return $configSource;
    }

    /**
     * @param array<string,mixed> $payload
     * @param array<string,bool> $permissions
     * @return array<string,mixed>
     */
    public function projectPathOutcomes(
        int $departmentId,
        array $payload,
        ?int $userId = null,
        array $permissions = [],
        ?callable $progressCallback = null
    ): array
    {
        $versioning = new selfserve_config_versioning();
        $configContext = $this->loadSimulationConfig($departmentId, $payload, $permissions, $versioning);
        $configSource = $configContext['config_source'];
        $config = $configContext['config'];
        $versionId = $configContext['version_id'];

        $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
        $includeHardware = $includeHardware !== false;
        $hardwareMode = strtolower(trim((string)($payload['hardware_mode'] ?? '')));
        if ($hardwareMode === '') {
            $hardwareMode = $includeHardware ? 'studio' : 'none';
        }
        if (!in_array($hardwareMode, ['studio', 'real', 'none'], true)) {
            $hardwareMode = 'studio';
        }
        if ($hardwareMode === 'none') {
            $includeHardware = false;
        }

        if (!$includeHardware || !($permissions['modules_shelly_config'] ?? false)) {
            $gatewayWorkspace = [
                'gateways' => [],
                'relays' => [],
                'lanes' => [],
                'issues' => [],
                'actions' => [],
                'restricted' => !$includeHardware ? false : true,
                'virtual' => [
                    'enabled' => $hardwareMode === 'studio',
                    'has_virtual_hardware' => false,
                    'gateway_count' => 0,
                    'binding_count' => 0,
                ],
            ];
        } else {
            $gatewayWorkspace = $this->buildGatewayWorkspace($departmentId, $hardwareMode !== 'real');
        }

        $graphConfig = is_array($config) ? $config : $versioning->snapshotLegacyConfig($departmentId);
        $lookups = $this->buildLookups($departmentId, $graphConfig, $gatewayWorkspace);
        $graph = $this->buildGraphFromConfig($graphConfig, [
            'department_id' => $departmentId,
            'lookups' => $lookups,
            'gateway_workspace' => $gatewayWorkspace,
        ]);
        $defaults = $this->buildSimulatorDefaults($departmentId, $lookups, $gatewayWorkspace);
        $laneId = $this->nullableInt($payload['lane_id'] ?? null) ?? $this->nullableInt($defaults['lane_id'] ?? null);
        if ($laneId === null) {
            throw new \RuntimeException('No lane is available for path outcome projection.');
        }

        $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? null);
        $vehicleTypeIds = [];
        if ($vehicleTypeId !== null) {
            $vehicleTypeIds[] = $vehicleTypeId;
        } else {
            foreach ($this->lookupRows($lookups, 'vehicle_types') as $row) {
                $id = $this->nullableInt($row['id'] ?? null);
                if ($id !== null) {
                    $vehicleTypeIds[$id] = $id;
                }
            }
            $vehicleTypeIds = array_values($vehicleTypeIds);
        }
        if ($vehicleTypeIds === []) {
            $vehicleTypeIds[] = null;
        }

        $maxStates = $this->pathLimit(
            $payload['max_states'] ?? null,
            self::DEFAULT_PATH_MAX_STATES,
            self::MAX_PATH_MAX_STATES
        );
        $reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
        if ($reg === '') {
            $reg = 'TEST123';
        }
        $customerNumber = array_key_exists('customer_number', $payload)
            ? $this->nullableInt($payload['customer_number'])
            : $this->nullableInt($defaults['customer_number'] ?? null);

        $flow = new selfserve_wash_flow();
        $outcomes = [];
        $warnings = [];
        $truncated = false;
        $stateCount = 0;
        $terminalPathCount = 0;
        $questionIds = [];
        $pathSampleLimit = $this->pathLimit(
            $payload['path_sample_limit'] ?? null,
            self::DEFAULT_PATH_SAMPLE_LIMIT,
            self::MAX_PATH_SAMPLE_LIMIT
        );
        $paths = [];
        $scenarioCount = max(1, count($vehicleTypeIds));
        $confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource);

        foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
            $remainingStates = $maxStates - $stateCount;
            if ($remainingStates <= 0) {
                $truncated = true;
                break;
            }

            $scenarioScope = [
                'department_id' => $departmentId,
                'department' => $this->labelFor('departments', $departmentId, $lookups),
                'lane_id' => $laneId,
                'lane' => $this->labelFor('lanes', $laneId, $lookups),
                'vehicle_type_id' => $scenarioVehicleTypeId,
                'vehicle_type' => $scenarioVehicleTypeId === null ? 'Auto' : $this->labelFor('vehicle_types', $scenarioVehicleTypeId, $lookups),
                'registration' => $reg,
                'customer_number' => $customerNumber,
                'config_source' => $configSource,
                'config_version_id' => $versionId,
                'hardware_mode' => $hardwareMode,
            ];
            $simulate = function (array $answerOverrides) use (
                $flow,
                $departmentId,
                $laneId,
                $reg,
                $customerNumber,
                $scenarioVehicleTypeId,
                $configSource,
                $graphConfig,
                $versionId,
                $includeHardware,
                $hardwareMode,
                $lookups,
                $gatewayWorkspace,
                $graph
            ): array {
                return $flow->previewStudioSimulation(
                    $departmentId,
                    $laneId,
                    $reg,
                    $customerNumber,
                    $scenarioVehicleTypeId,
                    [
                        'mode' => 'full_dry_run',
                        'config_source' => $configSource,
                        'config_payload' => $graphConfig,
                        'config_version_id' => $versionId,
                        'answer_overrides' => $answerOverrides,
                        'include_hardware' => $includeHardware,
                        'hardware_mode' => $hardwareMode,
                        'lookups' => $lookups,
                        'gateway_workspace' => $gatewayWorkspace,
                        'graph' => $graph,
                    ],
                );
            };

            $projectionOptions = [
                'scope' => $scenarioScope,
                'max_states' => $remainingStates,
                'path_sample_limit' => max(0, $pathSampleLimit - count($paths)),
                'progress_callback' => function (array $projection) use (
                    $progressCallback,
                    &$outcomes,
                    &$paths,
                    &$stateCount,
                    &$terminalPathCount,
                    &$questionIds,
                    $maxStates,
                    $pathSampleLimit,
                    $scenarioIndex,
                    $scenarioCount,
                    $departmentId,
                    $lookups,
                    $laneId,
                    $vehicleTypeId,
                    $reg,
                    $customerNumber,
                    $configSource,
                    $versionId,
                    $hardwareMode,
                    $confirmationRows
                ): void {
                    if ($progressCallback === null) {
                        return;
                    }

                    $partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
                    $partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
                    if (count($partialPaths) > $pathSampleLimit) {
                        $partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
                    }

                    $partialQuestionIds = $questionIds;
                    foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) {
                        $partialQuestionIds[(int)$questionId] = true;
                    }

                    $projectionProgress = is_array($projection['progress'] ?? null) ? (array)$projection['progress'] : [];
                    $scenarioPercent = (float)($projectionProgress['percent'] ?? 0);
                    $overallPercent = min(99.0, (($scenarioIndex + ($scenarioPercent / 100)) / $scenarioCount) * 100);

                    $partialPayload = $this->pathOutcomesPayload(
                        [
                            'department_id' => $departmentId,
                            'department' => $this->labelFor('departments', $departmentId, $lookups),
                            'lane_id' => $laneId,
                            'lane' => $this->labelFor('lanes', $laneId, $lookups),
                            'vehicle_type_id' => $vehicleTypeId,
                            'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups),
                            'vehicle_type_count' => $scenarioCount,
                            'registration' => $reg,
                            'customer_number' => $customerNumber,
                            'config_source' => $configSource,
                            'config_version_id' => $versionId,
                            'hardware_mode' => $hardwareMode,
                            'max_states' => $maxStates,
                        ],
                        $partialOutcomes,
                        $partialPaths,
                        [],
                        false,
                        $maxStates,
                        $stateCount + (int)($projection['summary']['state_count'] ?? 0),
                        $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0),
                        $partialQuestionIds,
                        [
                            'complete' => false,
                            'percent' => (int)floor($overallPercent),
                            'state_count' => $stateCount + (int)($projection['summary']['state_count'] ?? 0),
                            'pending_state_count' => (int)($projectionProgress['pending_state_count'] ?? 0),
                            'terminal_path_count' => $terminalPathCount + (int)($projection['summary']['terminal_path_count'] ?? 0),
                            'scenario_index' => $scenarioIndex + 1,
                            'scenario_count' => $scenarioCount,
                        ],
                        $confirmationRows
                    );
                    $progressCallback($partialPayload);
                },
                'confirmation_rows' => $confirmationRows,
            ];
            $projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
            foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
                if (is_array($outcome)) {
                    $outcomes[] = $outcome;
                }
            }
            foreach ((array)($projection['paths'] ?? []) as $path) {
                if (!is_array($path)) {
                    continue;
                }
                if (count($paths) < $pathSampleLimit) {
                    $paths[] = $path;
                }
            }
            foreach ((array)($projection['warnings'] ?? []) as $warning) {
                $warnings[] = (string)$warning;
            }
            $truncated = $truncated || (bool)($projection['truncated'] ?? false);
            $stateCount += (int)($projection['summary']['state_count'] ?? 0);
            $terminalPathCount += (int)($projection['summary']['terminal_path_count'] ?? 0);
            foreach ((array)($projection['summary']['question_ids'] ?? []) as $questionId) {
                $questionIds[(int)$questionId] = true;
            }
        }

        if ($truncated) {
            $warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s). Narrow the lane or vehicle type filters to inspect more paths.';
        }

        return $this->pathOutcomesPayload(
            [
                'department_id' => $departmentId,
                'department' => $this->labelFor('departments', $departmentId, $lookups),
                'lane_id' => $laneId,
                'lane' => $this->labelFor('lanes', $laneId, $lookups),
                'vehicle_type_id' => $vehicleTypeId,
                'vehicle_type' => $vehicleTypeId === null ? 'All current vehicle types' : $this->labelFor('vehicle_types', $vehicleTypeId, $lookups),
                'vehicle_type_count' => count($vehicleTypeIds),
                'registration' => $reg,
                'customer_number' => $customerNumber,
                'config_source' => $configSource,
                'config_version_id' => $versionId,
                'hardware_mode' => $hardwareMode,
                'max_states' => $maxStates,
            ],
            $outcomes,
            $paths,
            $warnings,
            $truncated,
            $maxStates,
            $stateCount,
            $terminalPathCount,
            $questionIds,
            [
                'complete' => true,
                'percent' => 100,
                'state_count' => $stateCount,
                'pending_state_count' => 0,
                'terminal_path_count' => $terminalPathCount,
                'scenario_index' => $scenarioCount,
                'scenario_count' => $scenarioCount,
            ],
            $confirmationRows
        );
    }

    /**
     * @param callable(array<int,array{question_id:int,value:bool}>):array<string,mixed> $simulate
     * @param array<string,mixed> $options
     * @return array<string,mixed>
     */
    public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
    {
        $maxStates = $this->pathLimit(
            $options['max_states'] ?? null,
            self::DEFAULT_PATH_MAX_STATES,
            self::MAX_PATH_MAX_STATES
        );
        $sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
        $pathSampleLimit = $this->pathLimit(
            $options['path_sample_limit'] ?? null,
            self::DEFAULT_PATH_SAMPLE_LIMIT,
            self::MAX_PATH_SAMPLE_LIMIT,
            0
        );
        $progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
        $progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
        $scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
        $confirmationRows = is_array($options['confirmation_rows'] ?? null) ? (array)$options['confirmation_rows'] : [];
        $stack = [[
            'answers' => [],
            'chain' => [],
        ]];
        $seenStates = [];
        $groups = [];
        $paths = [];
        $stateCount = 0;
        $terminalPathCount = 0;
        $questionIds = [];
        $truncated = false;

        while ($stack !== []) {
            if ($stateCount >= $maxStates) {
                $truncated = true;
                break;
            }

            $state = array_pop($stack);
            $answers = is_array($state['answers'] ?? null) ? (array)$state['answers'] : [];
            ksort($answers, SORT_NUMERIC);
            $stateKey = $this->stableJson($answers);
            if (isset($seenStates[$stateKey])) {
                continue;
            }
            $seenStates[$stateKey] = true;
            $stateCount++;

            $simulation = $simulate($this->pathAnswerOverrides($answers));
            $nextQuestion = $this->nextPathQuestion($simulation, $answers);
            if ($nextQuestion !== null) {
                $questionId = (int)($nextQuestion['id'] ?? 0);
                if ($questionId > 0) {
                    $questionIds[$questionId] = true;
                    foreach ([false, true] as $answerValue) {
                        $nextAnswers = $answers;
                        $nextAnswers[$questionId] = $answerValue;
                        ksort($nextAnswers, SORT_NUMERIC);
                        $nextChain = is_array($state['chain'] ?? null) ? array_values((array)$state['chain']) : [];
                        $nextChain[] = [
                            'question_id' => $questionId,
                            'question' => (string)($nextQuestion['label'] ?? $nextQuestion['question'] ?? ('Question ' . $questionId)),
                            'node_id' => (string)($nextQuestion['node_id'] ?? ('question:' . $questionId)),
                            'answer' => $answerValue,
                            'answer_label' => $answerValue ? 'Yes' : 'No',
                        ];
                        $stack[] = [
                            'answers' => $nextAnswers,
                            'chain' => $nextChain,
                        ];
                    }
                }
                if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) {
                    $progressCallback($this->pathOutcomesProjectionPayload(
                        $scope,
                        $groups,
                        $paths,
                        $truncated,
                        $maxStates,
                        $stateCount,
                        $terminalPathCount,
                        $questionIds,
                        count($stack),
                        $confirmationRows
                    ));
                }
                continue;
            }

            $terminalPathCount++;
            $chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
            $this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
            if (count($paths) < $pathSampleLimit) {
                $paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
            }

            if ($progressCallback !== null && $stateCount % $progressIntervalStates === 0) {
                $progressCallback($this->pathOutcomesProjectionPayload(
                    $scope,
                    $groups,
                    $paths,
                    $truncated,
                    $maxStates,
                    $stateCount,
                    $terminalPathCount,
                    $questionIds,
                    count($stack),
                    $confirmationRows
                ));
            }
        }

        return $this->pathOutcomesProjectionPayload(
            $scope,
            $groups,
            $paths,
            $truncated,
            $maxStates,
            $stateCount,
            $terminalPathCount,
            $questionIds,
            count($stack),
            $confirmationRows
        );
    }

    /**
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    public function confirmPathOutcome(int $departmentId, array $payload, ?int $userId): array
    {
        $pathSignature = trim((string)($payload['path_signature'] ?? ''));
        $resultSignature = trim((string)($payload['result_signature'] ?? ''));
        if ($pathSignature === '' || $resultSignature === '') {
            throw new \RuntimeException('path_signature and result_signature are required.');
        }

        $scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : [];
        $laneId = $this->nullableInt($payload['lane_id'] ?? $scope['lane_id'] ?? null);
        $vehicleTypeId = $this->nullableInt($payload['vehicle_type_id'] ?? $scope['vehicle_type_id'] ?? null);
        $configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null);
        $configSource = strtolower(trim((string)($payload['config_source'] ?? $scope['config_source'] ?? 'draft')));
        if ($configSource === '') {
            $configSource = 'draft';
        }
        $answers = is_array($payload['answers'] ?? null) ? array_values((array)$payload['answers']) : [];
        $result = is_array($payload['result'] ?? null) ? (array)$payload['result'] : [];

        $answersJson = json_encode($this->sortStableValue($answers), JSON_UNESCAPED_UNICODE);
        $resultJson = json_encode($this->sortStableValue($result), JSON_UNESCAPED_UNICODE);
        $scopeJson = json_encode($this->sortStableValue($scope), JSON_UNESCAPED_UNICODE);
        if ($answersJson === false || $resultJson === false || $scopeJson === false) {
            throw new \RuntimeException('Could not encode path confirmation payload.');
        }

        $pdo = db::getPDO();
        $select = $pdo->prepare(
            "SELECT id
             FROM department_selfserve_path_confirmations
             WHERE department_id = :department_id
               AND path_signature = :path_signature
               AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . "
               AND deleted_at IS NULL
             ORDER BY id DESC
             LIMIT 1"
        );
        $selectParams = [
            ':department_id' => $departmentId,
            ':path_signature' => $pathSignature,
        ];
        if ($configVersionId !== null) {
            $selectParams[':config_version_id'] = $configVersionId;
        }
        $select->execute($selectParams);
        $row = $select->fetch(\PDO::FETCH_ASSOC);

        if (is_array($row) && (int)($row['id'] ?? 0) > 0) {
            $update = $pdo->prepare(
                "UPDATE department_selfserve_path_confirmations
                 SET lane_id = :lane_id,
                     vehicle_type_id = :vehicle_type_id,
                     config_source = :config_source,
                     result_signature = :result_signature,
                     answers_json = :answers_json,
                     result_json = :result_json,
                     scope_json = :scope_json,
                     confirmed_by = :confirmed_by,
                     confirmed_at = NOW(),
                     stale_reason = NULL,
                     deleted_at = NULL
                 WHERE id = :id"
            );
            $update->execute([
                ':lane_id' => $laneId,
                ':vehicle_type_id' => $vehicleTypeId,
                ':config_source' => $configSource,
                ':result_signature' => $resultSignature,
                ':answers_json' => $answersJson,
                ':result_json' => $resultJson,
                ':scope_json' => $scopeJson,
                ':confirmed_by' => $userId,
                ':id' => (int)$row['id'],
            ]);
            $id = (int)$row['id'];
        } else {
            $insert = $pdo->prepare(
                "INSERT INTO department_selfserve_path_confirmations
                    (department_id, lane_id, vehicle_type_id, config_version_id, config_source, path_signature, result_signature, answers_json, result_json, scope_json, confirmed_by, confirmed_at)
                 VALUES
                    (:department_id, :lane_id, :vehicle_type_id, :config_version_id, :config_source, :path_signature, :result_signature, :answers_json, :result_json, :scope_json, :confirmed_by, NOW())"
            );
            $insert->execute([
                ':department_id' => $departmentId,
                ':lane_id' => $laneId,
                ':vehicle_type_id' => $vehicleTypeId,
                ':config_version_id' => $configVersionId,
                ':config_source' => $configSource,
                ':path_signature' => $pathSignature,
                ':result_signature' => $resultSignature,
                ':answers_json' => $answersJson,
                ':result_json' => $resultJson,
                ':scope_json' => $scopeJson,
                ':confirmed_by' => $userId,
            ]);
            $id = (int)$pdo->lastInsertId();
        }

        return [
            'id' => $id,
            'department_id' => $departmentId,
            'lane_id' => $laneId,
            'vehicle_type_id' => $vehicleTypeId,
            'config_version_id' => $configVersionId,
            'config_source' => $configSource,
            'path_signature' => $pathSignature,
            'result_signature' => $resultSignature,
            'answers' => $answers,
            'result' => $result,
            'scope' => $scope,
            'confirmation_status' => 'confirmed',
        ];
    }

    /**
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    public function resetPathConfirmation(int $departmentId, array $payload): array
    {
        $pathSignature = trim((string)($payload['path_signature'] ?? ''));
        if ($pathSignature === '') {
            throw new \RuntimeException('path_signature is required.');
        }
        $scope = is_array($payload['scope'] ?? null) ? (array)$payload['scope'] : [];
        $configVersionId = $this->nullableInt($payload['config_version_id'] ?? $scope['config_version_id'] ?? null);
        $pdo = db::getPDO();
        $statement = $pdo->prepare(
            "UPDATE department_selfserve_path_confirmations
             SET deleted_at = NOW()
             WHERE department_id = :department_id
               AND path_signature = :path_signature
               AND " . ($configVersionId === null ? "config_version_id IS NULL" : "config_version_id = :config_version_id") . "
               AND deleted_at IS NULL"
        );
        $params = [
            ':department_id' => $departmentId,
            ':path_signature' => $pathSignature,
        ];
        if ($configVersionId !== null) {
            $params[':config_version_id'] = $configVersionId;
        }
        $statement->execute($params);

        return [
            'path_signature' => $pathSignature,
            'reset' => true,
            'affected' => $statement->rowCount(),
        ];
    }

    /**
     * @param array<string,mixed> $payload
     * @return array<string,mixed>
     */
    public function runGatewayAction(int $departmentId, int $gatewayId, string $action, array $payload, ?int $userId): array
    {
        if (!class_exists(edge_gateway_view_service::class)) {
            throw new \RuntimeException('Edge gateway module is not available.');
        }

        $gateway = (new edge_gateway_view_service())->getGateway($gatewayId);
        if (!isset($gateway['id'])) {
            throw new \RuntimeException('Edge gateway not found.');
        }
        if ((int)($gateway['department_id'] ?? 0) !== $departmentId) {
            throw new \RuntimeException('Edge gateway does not belong to the selected department.');
        }

        $action = strtolower(trim($action));
        $operations = new edge_gateway_operation_service();
        return match ($action) {
            'discovery', 'discover' => [
                'action' => 'discovery',
                'operation' => $operations->queueDiscoveryOperation($gatewayId, $userId),
            ],
            'update' => [
                'action' => 'update',
                'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UPDATE, (array)($payload['request'] ?? []), $userId),
            ],
            'uninstall' => [
                'action' => 'uninstall',
                'operation' => $operations->queueOperation($gatewayId, edge_gateway_operation_service::TYPE_UNINSTALL, (array)($payload['request'] ?? []), $userId),
            ],
            'cancel' => [
                'action' => 'cancel',
                'operation' => $operations->cancelOperation($gatewayId, (int)($payload['operation_id'] ?? 0), $userId),
            ],
            'rotate_credentials' => [
                'action' => 'rotate_credentials',
                'gateway' => $operations->rotateCredentials($gatewayId, $userId),
            ],
            'bindings' => [
                'action' => 'bindings',
                'gateway' => (new edge_gateway_registry_service())->setRelayBindings($gatewayId, (array)($payload['bindings'] ?? []), $userId),
            ],
            default => throw new \RuntimeException('Unsupported gateway action: ' . $action),
        };
    }

    /**
     * @param array<string,mixed> $payload
     * @param array<string,bool> $permissions
     * @return array<string,mixed>
     */
    public function applyVirtualHardwareOperation(int $departmentId, array $payload, ?int $userId, array $permissions = []): array
    {
        if (($permissions['can_edit'] ?? false) !== true) {
            throw new \RuntimeException('You do not have permission to edit self-serve studio hardware.');
        }

        $operation = strtolower(trim((string)($payload['operation'] ?? $payload['action'] ?? '')));
        $data = is_array($payload['data'] ?? null) ? (array)$payload['data'] : $payload;
        $realWorkspace = $this->buildGatewayWorkspace($departmentId, false);
        (new selfserve_virtual_hardware())->applyOperation($departmentId, $operation, $data, $userId, $realWorkspace);

        return $this->buildGraph($departmentId, $userId, $permissions);
    }

    /**
     * @return array<string,mixed>
     */
    private function buildGatewayWorkspace(int $departmentId, bool $includeVirtual = true): array
    {
        if (!class_exists(edge_gateway_department_workspace_service::class)) {
            $workspace = [
                'gateways' => [],
                'relays' => [],
                'lanes' => [],
                'issues' => [],
                'actions' => [],
                'available' => false,
            ];
            return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace;
        }

        try {
            $workspace = (new edge_gateway_department_workspace_service())->getDepartmentWorkspace($departmentId);
        } catch (\Throwable $exception) {
            $workspace = [
                'gateways' => [],
                'relays' => [],
                'lanes' => [],
                'issues' => [
                    [
                        'severity' => 'warning',
                        'message' => $exception->getMessage(),
                    ],
                ],
                'actions' => [],
                'available' => false,
            ];
        }

        return $includeVirtual ? (new selfserve_virtual_hardware())->mergeWorkspace($workspace, $departmentId) : $workspace;
    }

    /**
     * @param array<string,mixed> $config
     * @param array<string,mixed> $gatewayWorkspace
     * @return array<string,mixed>
     */
    private function buildLookups(int $departmentId, array $config, array $gatewayWorkspace): array
    {
        $departmentRows = $this->fetchRows('departments', ['id', 'name', 'description'], ['id' => $departmentId]);
        $laneRows = $this->fetchRows('department_lanes', [
            'id',
            'department',
            'name',
            'relay_in_id',
            'relay_out_id',
            'relay_machine_id',
            'relay_machine_program_picker_id',
            'relay_machine_cleaner_id',
            'machine_type_id',
            'dynamic_image_id',
            'selfserve_enabled',
        ], ['department' => $departmentId]);
        $machineTypeRows = $this->fetchRows('selfserve_machine_types', ['id', 'name', 'description'], []);
        $productRows = $this->fetchRows('products', ['id', 'name', 'description', 'price', 'subscription_allowed', 'category', 'piktogram', 'is_wash', 'order_priority'], []);
        $vehicleTypeRows = $this->vehicleTypeRowsFromProducts($productRows);
        $users = $this->fetchRows('users', ['id', 'customer_number', 'display_name'], []);
        $laneLookupRows = $this->labelRows($laneRows, 'name');
        $machineTypeLookupRows = $this->addReferencedMachineTypeRows($this->labelRows($machineTypeRows, 'name'), $laneRows, $config);

        $lookups = [
            'departments' => $this->labelRows($departmentRows, 'name'),
            'lanes' => $laneLookupRows,
            'products' => $this->labelRows($productRows, 'name'),
            'machine_types' => $machineTypeLookupRows,
            'dynamic_images' => $this->dynamicImageRowsFromLanes($laneRows),
            'vehicle_types' => $vehicleTypeRows,
            'questions' => $this->configLabelRows((array)($config['questions'] ?? []), 'question'),
            'conditions' => $this->configLabelRows((array)($config['conditions'] ?? []), 'name'),
            'rules' => $this->configLabelRows((array)($config['rules'] ?? []), 'name'),
            'tasks' => $this->configLabelRows((array)($config['tasks'] ?? []), 'task'),
            'actions' => $this->configLabelRows((array)($config['actions'] ?? []), 'name'),
            'gateways' => $this->gatewayLabelRows((array)($gatewayWorkspace['gateways'] ?? [])),
            'relays' => $this->relayLabelRows((array)($gatewayWorkspace['relays'] ?? [])),
            'bindings' => $this->bindingLabelRows((array)($gatewayWorkspace['gateways'] ?? [])),
            'users' => array_map(static function (array $row): array {
                $label = trim((string)($row['display_name'] ?? ''));
                if ($label === '') {
                    $label = 'User ' . (string)($row['customer_number'] ?? $row['id'] ?? '');
                }
                $row['label'] = $label;
                return $row;
            }, $users),
        ];

        $labels = [];
        foreach ($lookups as $type => $rows) {
            if (!is_array($rows)) {
                continue;
            }
            $labels[$type] = [];
            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $id = (string)($row['id'] ?? '');
                if ($id !== '') {
                    $labels[$type][$id] = (string)($row['label'] ?? $id);
                }
            }
        }
        $lookups['labels'] = $labels;

        return $lookups;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @param array<int,array<string,mixed>> $laneRows
     * @param array<string,mixed> $config
     * @return array<int,array<string,mixed>>
     */
    private function addReferencedMachineTypeRows(array $rows, array $laneRows, array $config): array
    {
        $rowsById = [];
        foreach ($rows as $row) {
            $id = $this->nullableInt($row['id'] ?? null);
            if ($id === null) {
                continue;
            }
            $row['id'] = $id;
            $row['label'] = trim((string)($row['label'] ?? $row['name'] ?? '')) ?: 'Machine type ' . $id;
            $rowsById[$id] = $row;
        }

        foreach ($this->referencedMachineTypeIds($laneRows, $config) as $id) {
            if (!isset($rowsById[$id])) {
                $rowsById[$id] = [
                    'id' => $id,
                    'name' => 'Machine type ' . $id,
                    'label' => 'Machine type ' . $id,
                    'referenced' => true,
                ];
            }
        }

        ksort($rowsById, SORT_NUMERIC);
        return array_values($rowsById);
    }

    /**
     * @param array<int,array<string,mixed>> $laneRows
     * @param array<string,mixed> $config
     * @return array<int,int>
     */
    private function referencedMachineTypeIds(array $laneRows, array $config): array
    {
        $ids = [];
        foreach ($laneRows as $row) {
            $id = $this->nullableInt($row['machine_type_id'] ?? null);
            if ($id !== null) {
                $ids[$id] = $id;
            }
        }

        foreach (['conditions', 'questions', 'tasks', 'actions'] as $section) {
            foreach ((array)($config[$section] ?? []) as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $id = $this->nullableInt($row['machine_type_id'] ?? null);
                if ($id !== null) {
                    $ids[$id] = $id;
                }
            }
        }

        ksort($ids, SORT_NUMERIC);
        return array_values($ids);
    }

    /**
     * @param array<int,array<string,mixed>> $laneRows
     * @return array<int,array<string,mixed>>
     */
    private function dynamicImageRowsFromLanes(array $laneRows): array
    {
        $rowsById = [];
        foreach ($this->supportedDynamicImageRows() as $row) {
            $id = $this->nullableInt($row['id'] ?? null);
            if ($id !== null) {
                $rowsById[$id] = $row;
            }
        }

        foreach ($laneRows as $lane) {
            $id = $this->nullableInt($lane['dynamic_image_id'] ?? null);
            if ($id === null || isset($rowsById[$id])) {
                continue;
            }
            $rowsById[$id] = [
                'id' => $id,
                'name' => 'Dynamic image ' . $id,
                'label' => 'Dynamic image ' . $id,
                'referenced' => true,
            ];
        }

        ksort($rowsById, SORT_NUMERIC);
        return array_values($rowsById);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function supportedDynamicImageRows(): array
    {
        return [
            [
                'id' => 1,
                'name' => 'Machine 1',
                'label' => 'Machine 1',
                'class' => 'dynamicimages\\images\\machine_1',
            ],
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $nodes
     * @param array<int,array<string,mixed>> $edges
     * @param array<string,mixed> $workspace
     */
    private function appendGatewayNodesAndEdges(array &$nodes, array &$edges, array $workspace): void
    {
        $relayServices = $this->relayServicesFromWorkspace($workspace);

        foreach ((array)($workspace['gateways'] ?? []) as $index => $gateway) {
            if (!is_array($gateway)) {
                continue;
            }
            $gatewayId = $this->gatewayIdentifier($gateway);
            if ($gatewayId === '') {
                continue;
            }

            $nodeId = $this->gatewayNodeId($gateway);
            $nodes[] = $this->node($nodeId, 'default', (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)), 'edge_gateway', [
                'object_id' => $gatewayId,
                'raw' => $gateway,
                'subtitle' => (string)($gateway['status'] ?? 'UNKNOWN'),
            ], 1440, 160 + ($index * 150));

            foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) {
                if (!is_array($binding)) {
                    continue;
                }
                $relayId = trim((string)($binding['relay_id'] ?? ''));
                if ($relayId === '') {
                    continue;
                }
                $bindingServices = $this->bindingServices($binding, $relayId, $relayServices);
                if ($bindingServices !== []) {
                    $binding['services'] = $bindingServices;
                if (trim((string)($binding['role'] ?? '')) === '' && count($bindingServices) === 1) {
                        $binding['role'] = $bindingServices[0];
                    }
                }
                $bindingId = $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding);
                $nodes[] = $this->node($bindingId, 'default', (string)($binding['label'] ?? ('Relay ' . $relayId)), 'relay_binding', [
                    'object_id' => $relayId,
                    'raw' => $binding,
                    'subtitle' => (string)($binding['role'] ?? 'Relay binding'),
                ], 1720, 180 + (($index * 4 + $bindingIndex) * 100));
                $edges[] = $this->edge('gateway-binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $nodeId, $bindingId, 'gateway_binding', 'binds');
                $edges[] = $this->edge('binding-relay:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex, $bindingId, 'relay:' . $relayId, 'relay_binding', 'controls');
            }
        }

        $relayIndex = 0;
        foreach ((array)($workspace['relays'] ?? []) as $relay) {
            if (!is_array($relay)) {
                continue;
            }
            $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
            if ($relayId === '') {
                continue;
            }
            $nodes[] = $this->node('relay:' . $relayId, 'default', (string)($relay['name'] ?? ('Relay ' . $relayId)), 'relay', [
                'object_id' => $relayId,
                'raw' => $relay,
                'subtitle' => 'Hardware relay',
            ], 2020, 180 + ($relayIndex * 100));
            $relayIndex++;
        }

        foreach ((array)($workspace['lanes'] ?? []) as $lane) {
            if (!is_array($lane)) {
                continue;
            }
            $laneId = (int)($lane['id'] ?? 0);
            foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
                if (!is_array($slot)) {
                    continue;
                }
                $relayId = trim((string)($slot['relay_id'] ?? ''));
                if ($laneId > 0 && $relayId !== '') {
                    $edges[] = $this->edge('relay-lane:' . $relayId . ':' . $laneId . ':' . (string)($slot['slot'] ?? ''), 'relay:' . $relayId, 'lane:' . $laneId, 'lane_relay', (string)($slot['slot'] ?? 'relay'));
                }
            }
        }
    }

    /**
     * @param array<int,array<string,mixed>> $edges
     * @param array<int,array<string,mixed>> $tasks
     * @param array<string,mixed> $workspace
     */
    private function appendTaskServiceEdges(array &$edges, array $tasks, array $workspace): void
    {
        $bindingsByService = [];
        foreach ($this->gatewayBindingReferences($workspace) as $binding) {
            foreach ((array)$binding['services'] as $service) {
                $bindingsByService[$service][] = $binding;
            }
        }

        foreach ($tasks as $task) {
            $taskId = (int)($task['id'] ?? 0);
            if ($taskId <= 0) {
                continue;
            }
            foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) {
                foreach ($bindingsByService[$service] ?? [] as $binding) {
                    $edges[] = $this->edge(
                        'task-service:' . $taskId . ':' . $service . ':' . $binding['gateway_id'] . ':' . $binding['relay_id'] . ':' . $binding['binding_index'],
                        'task:' . $taskId,
                        (string)$binding['node_id'],
                        'task_service',
                        $service
                    );
                }
            }
        }
    }

    /**
     * @param array<int,array<string,mixed>> $edges
     * @param array<int,array<string,mixed>> $actions
     * @param array<string,mixed> $workspace
     */
    private function appendActionRelayEdges(array &$edges, array $actions, array $workspace): void
    {
        foreach ($actions as $action) {
            if (!is_array($action)) {
                continue;
            }
            $normalizedAction = selfserve_studio_actions::normalize($action);
            $actionId = (int)($normalizedAction['id'] ?? 0);
            if ($actionId <= 0) {
                continue;
            }

            $relayRole = $this->normalizeServiceName(selfserve_studio_actions::relayRoleForOperation((string)$normalizedAction['operation']));
            if ($relayRole === '' || $relayRole === 'ACTION') {
                continue;
            }

            $laneId = (int)($normalizedAction['lane'] ?? 0);
            foreach ($this->actionRelayTargets($workspace, $relayRole, $laneId) as $target) {
                $edges[] = $this->edge(
                    'action-relay:' . $actionId . ':' . $target['relay_id'] . ':' . $relayRole . ':' . $target['lane_id'],
                    'action:' . $actionId,
                    'relay:' . $target['relay_id'],
                    'action_relay',
                    $relayRole
                );
            }
        }
    }

    /**
     * @param array<string,mixed> $workspace
     * @return array<int,array{relay_id:string,lane_id:int}>
     */
    private function actionRelayTargets(array $workspace, string $relayRole, int $actionLaneId): array
    {
        $targets = [];
        $seen = [];
        foreach ((array)($workspace['lanes'] ?? []) as $lane) {
            if (!is_array($lane)) {
                continue;
            }
            $laneId = (int)($lane['id'] ?? 0);
            if ($laneId <= 0 || ($actionLaneId > 0 && $laneId !== $actionLaneId)) {
                continue;
            }

            foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
                if (!is_array($slot)) {
                    continue;
                }
                $slotRole = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '');
                $relayId = trim((string)($slot['relay_id'] ?? ''));
                if ($relayId === '' || $slotRole !== $relayRole) {
                    continue;
                }

                $key = $laneId . ':' . $relayRole . ':' . $relayId;
                if (isset($seen[$key])) {
                    continue;
                }
                $seen[$key] = true;
                $targets[] = [
                    'relay_id' => $relayId,
                    'lane_id' => $laneId,
                ];
            }
        }

        return $targets;
    }

    /**
     * @param array<string,mixed> $config
     * @param array<string,mixed> $operation
     */
    private function applyConfigOperation(int $departmentId, array &$config, array $operation, array $permissions = []): void
    {
        $action = strtolower((string)($operation['action'] ?? ''));
        $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
        $data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : [];
        $id = (int)($operation['id'] ?? $data['id'] ?? 0);

        if ($action === 'connect') {
            $this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false);
            return;
        }
        if ($action === 'disconnect') {
            $this->applyConfigConnection($departmentId, $config, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true);
            return;
        }
        if ($action === 'reorder') {
            $this->applyConfigReorder($config, $entity, (array)($operation['items'] ?? []));
            return;
        }
        if ($action === 'upsert_path' || ($entity === 'path' && $action === 'upsert')) {
            $this->upsertConfigPath($departmentId, $config, $data);
            return;
        }
        if ($entity === '') {
            throw new \RuntimeException('Studio graph operation is missing entity.');
        }
        if ($entity === 'lane') {
            $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions);
            return;
        }
        if ($entity === 'rule') {
            throw new \RuntimeException('Standalone rule operations are not supported in self-serve rules v2.');
        }

        if ($action === 'create') {
            $this->createConfigEntity($departmentId, $config, $entity, $data);
            return;
        }
        if ($id <= 0) {
            throw new \RuntimeException('Studio graph operation is missing id.');
        }
        if ($action === 'update') {
            $this->updateConfigEntity($departmentId, $config, $entity, $id, $data);
            return;
        }
        if ($action === 'delete') {
            $this->deleteConfigEntity($config, $entity, $id);
            return;
        }

        throw new \RuntimeException('Unsupported studio graph operation: ' . $action);
    }

    /**
     * @param array<string,mixed> $config
     * @param array<string,mixed> $data
     */
    private function upsertConfigPath(int $departmentId, array &$config, array $data): void
    {
        $scope = $this->normalizePathEditorScope($departmentId, is_array($data['scope'] ?? null) ? (array)$data['scope'] : $data);
        $answers = $this->normalizePathEditorAnswers($data['answers'] ?? []);
        if ($answers === []) {
            throw new \RuntimeException('Path editor operation requires at least one answer.');
        }
        $this->assertPathEditorQuestionsExist($config, $answers);

        $result = $this->normalizePathEditorResult(is_array($data['result'] ?? null) ? (array)$data['result'] : $data);
        $previousPathKey = trim((string)($data['previous_path_key'] ?? ''));
        $pathKey = trim((string)($data['path_key'] ?? ''));
        if ($pathKey === '') {
            $pathKey = $previousPathKey !== '' ? $previousPathKey : $this->pathEditorPathKey($scope, $answers);
        }

        if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) {
            $config['v2_meta'] = [];
        }
        if (!isset($config['v2_meta']['path_editor']) || !is_array($config['v2_meta']['path_editor'])) {
            $config['v2_meta']['path_editor'] = [];
        }
        if (!isset($config['v2_meta']['path_editor']['paths']) || !is_array($config['v2_meta']['path_editor']['paths'])) {
            $config['v2_meta']['path_editor']['paths'] = [];
        }
        if ($previousPathKey !== '' && $previousPathKey !== $pathKey && isset($config['v2_meta']['path_editor']['paths'][$previousPathKey])) {
            $config['v2_meta']['path_editor']['paths'][$pathKey] = $config['v2_meta']['path_editor']['paths'][$previousPathKey];
            unset($config['v2_meta']['path_editor']['paths'][$previousPathKey]);
        }

        $paths = &$config['v2_meta']['path_editor']['paths'];
        $existing = is_array($paths[$pathKey] ?? null) ? (array)$paths[$pathKey] : [];
        $conditionId = $this->nullableInt($existing['condition_id'] ?? $data['condition_id'] ?? $data['existing_condition_id'] ?? null);
        $existingTaskIds = $this->pathEditorExistingTaskIds($existing, $data);
        $conditionId = $this->upsertPathEditorCondition($departmentId, $config, $pathKey, $scope, $answers, $result, $conditionId);

        $taskIds = [];
        if ((bool)$result['machine_allowed']) {
            $baseOrderPriority = $this->pathEditorTaskBaseOrderPriority($config, $existingTaskIds);
            foreach (array_values((array)($result['tasks'] ?? [])) as $index => $taskResult) {
                if (!is_array($taskResult)) {
                    continue;
                }
                $taskIds[] = $this->upsertPathEditorTask(
                    $departmentId,
                    $config,
                    $pathKey,
                    $scope,
                    $taskResult,
                    $conditionId,
                    $existingTaskIds[$index] ?? null,
                    $baseOrderPriority + ($index * 10)
                );
            }
            foreach (array_slice($existingTaskIds, count($taskIds)) as $staleTaskId) {
                $this->deleteConfigEntity($config, 'task', $staleTaskId);
            }
        } else {
            foreach ($existingTaskIds as $staleTaskId) {
                $this->deleteConfigEntity($config, 'task', $staleTaskId);
            }
        }

        $taskId = $taskIds[0] ?? null;
        $pathSignature = $this->pathSignature($scope, $answers);
        $resultSignature = $this->pathEditorResultSignature($result, $taskIds);
        $paths[$pathKey] = [
            'path_key' => $pathKey,
            'condition_id' => $conditionId,
            'task_id' => $taskId,
            'task_ids' => $taskIds,
            'scope' => $scope,
            'answers' => $answers,
            'result' => $result,
            'path_signature' => $pathSignature,
            'result_signature' => $resultSignature,
            'updated_at' => date('c'),
        ];
        unset($paths);
    }

    /**
     * @param array<string,mixed> $scope
     * @return array<string,mixed>
     */
    private function normalizePathEditorScope(int $departmentId, array $scope): array
    {
        return [
            'department_id' => $departmentId,
            'lane_id' => $this->nullableInt($scope['lane_id'] ?? $scope['lane'] ?? null),
            'vehicle_type_id' => $this->nullableInt($scope['vehicle_type_id'] ?? $scope['product'] ?? $scope['product_id'] ?? null),
            'machine_type_id' => $this->nullableInt($scope['machine_type_id'] ?? null),
            'config_source' => strtolower(trim((string)($scope['config_source'] ?? 'draft'))) ?: 'draft',
            'hardware_mode' => strtolower(trim((string)($scope['hardware_mode'] ?? 'studio'))) ?: 'studio',
        ];
    }

    /**
     * @return array<int,array{question_id:int,value:bool,answer:bool,answer_label:string}>
     */
    private function normalizePathEditorAnswers(mixed $answers): array
    {
        $rows = [];
        if (!is_array($answers)) {
            return $rows;
        }

        foreach ($answers as $key => $entry) {
            if (is_array($entry)) {
                $questionId = (int)($entry['question_id'] ?? $entry['id'] ?? $key);
                $rawValue = $entry['value'] ?? $entry['answer'] ?? null;
            } else {
                $questionId = (int)$key;
                $rawValue = $entry;
            }
            if ($questionId <= 0) {
                continue;
            }
            $value = filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
            if ($value === null) {
                continue;
            }
            $rows[] = [
                'question_id' => $questionId,
                'value' => (bool)$value,
                'answer' => (bool)$value,
                'answer_label' => (bool)$value ? 'Yes' : 'No',
            ];
        }

        return $rows;
    }

    /**
     * @param array<string,mixed> $config
     * @param array<int,array{question_id:int,value:bool,answer:bool,answer_label:string}> $answers
     */
    private function assertPathEditorQuestionsExist(array $config, array $answers): void
    {
        $questionIds = [];
        foreach ((array)($config['questions'] ?? []) as $question) {
            if (is_array($question)) {
                $questionIds[(int)($question['id'] ?? 0)] = true;
            }
        }
        foreach ($answers as $answer) {
            $questionId = (int)($answer['question_id'] ?? 0);
            if ($questionId > 0 && !isset($questionIds[$questionId])) {
                throw new \RuntimeException('Path editor answer references unknown question ' . $questionId . '.');
            }
        }
    }

    /**
     * @param array<string,mixed> $result
     * @return array<string,mixed>
     */
    private function normalizePathEditorResult(array $result): array
    {
        $machineAllowed = filter_var($result['machine_allowed'] ?? $result['allowed'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
        $machineAllowed = $machineAllowed !== false;
        $services = $machineAllowed ? $this->normalizeServiceList($result['services'] ?? ['MACHINE']) : $this->normalizeServiceList($result['services'] ?? []);
        if ($machineAllowed && !in_array('MACHINE', $services, true)) {
            $services[] = 'MACHINE';
            sort($services);
        }

        try {
            $buttons = department_selfserve_tasks_o::normalizeButtonsInput($result['buttons'] ?? []);
        } catch (\Throwable $exception) {
            throw new \RuntimeException('Invalid path editor buttons: ' . $exception->getMessage());
        }

        $dynamicImagesVehicleType = $this->nullableInt($result['dynamic_images_vehicle_type'] ?? null);
        $hasTaskList = array_key_exists('tasks', $result) && is_array($result['tasks']);
        $tasks = [];
        if ($machineAllowed) {
            if ($hasTaskList) {
                foreach (array_values((array)$result['tasks']) as $index => $task) {
                    if (!is_array($task)) {
                        continue;
                    }
                    $tasks[] = $this->normalizePathEditorTaskResult((array)$task, $services, $index);
                }
            } else {
                $tasks[] = $this->normalizePathEditorTaskResult([
                    'task' => $result['task'] ?? $result['task_text'] ?? 'Start machine',
                    'description' => $result['description'] ?? '',
                    'services' => $services,
                    'buttons' => $buttons,
                    'dynamic_images_vehicle_type' => $dynamicImagesVehicleType,
                ], $services, 0);
            }
        }

        if ($hasTaskList) {
            $buttons = $this->flattenPathEditorTaskButtons($tasks);
            $services = $this->mergePathEditorTaskServices($services, $tasks, $machineAllowed);
            if ($dynamicImagesVehicleType === null) {
                foreach ($tasks as $task) {
                    $dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null);
                    if ($dynamicImagesVehicleType !== null) {
                        break;
                    }
                }
            }
        }

        return [
            'machine_allowed' => $machineAllowed,
            'task' => trim((string)($result['task'] ?? $result['task_text'] ?? 'Start machine')) ?: 'Start machine',
            'description' => trim((string)($result['description'] ?? '')),
            'services' => $services,
            'buttons' => $buttons,
            'dynamic_images_vehicle_type' => $dynamicImagesVehicleType,
            'tasks' => $tasks,
            'condition_name' => trim((string)($result['condition_name'] ?? '')),
        ];
    }

    /**
     * @param array<string,mixed> $task
     * @param array<int,string> $fallbackServices
     * @return array<string,mixed>
     */
    private function normalizePathEditorTaskResult(array $task, array $fallbackServices, int $index): array
    {
        try {
            $buttons = department_selfserve_tasks_o::normalizeButtonsInput($task['buttons'] ?? []);
        } catch (\Throwable $exception) {
            throw new \RuntimeException('Invalid path editor task buttons: ' . $exception->getMessage());
        }

        $services = $this->normalizeServiceList($task['services'] ?? $fallbackServices);
        if (!in_array('MACHINE', $services, true)) {
            $services[] = 'MACHINE';
        }

        $dynamicImagesVehicleType = $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null);
        if (($dynamicImagesVehicleType !== null || in_array('program_picker', $buttons, true)) && !in_array('PROGRAM_PICKER', $services, true)) {
            $services[] = 'PROGRAM_PICKER';
        }
        sort($services);

        return [
            'task' => trim((string)($task['task'] ?? $task['label'] ?? ('Task ' . ($index + 1)))) ?: ('Task ' . ($index + 1)),
            'description' => trim((string)($task['description'] ?? '')),
            'services' => $services,
            'buttons' => $buttons,
            'dynamic_images_vehicle_type' => $dynamicImagesVehicleType,
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $tasks
     * @return array<int,mixed>
     */
    private function flattenPathEditorTaskButtons(array $tasks): array
    {
        $buttons = [];
        foreach ($tasks as $task) {
            if (!is_array($task)) {
                continue;
            }
            foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) {
                $buttons[] = $button;
            }
        }

        return department_selfserve_tasks_o::normalizeButtonsInput($buttons);
    }

    /**
     * @param array<int,string> $services
     * @param array<int,array<string,mixed>> $tasks
     * @return array<int,string>
     */
    private function mergePathEditorTaskServices(array $services, array $tasks, bool $machineAllowed): array
    {
        $merged = [];
        foreach ($this->normalizeServiceList($services) as $service) {
            $merged[$service] = true;
        }
        foreach ($tasks as $task) {
            if (!is_array($task)) {
                continue;
            }
            foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) {
                $merged[$service] = true;
            }
        }
        if ($machineAllowed) {
            $merged['MACHINE'] = true;
        }

        $values = array_keys($merged);
        sort($values);
        return $values;
    }

    /**
     * @param array<string,mixed> $scope
     * @param array<int,array<string,mixed>> $answers
     */
    private function pathEditorPathKey(array $scope, array $answers): string
    {
        return 'path_' . substr(hash('sha256', $this->stableJson([
            'scope' => $scope,
            'answers' => $answers,
        ])), 0, 16);
    }

    /**
     * @param array<string,mixed> $config
     * @param array<string,mixed> $scope
     * @param array<int,array<string,mixed>> $answers
     * @param array<string,mixed> $result
     */
    private function upsertPathEditorCondition(int $departmentId, array &$config, string $pathKey, array $scope, array $answers, array $result, ?int $conditionId): int
    {
        if ($conditionId === null || $this->configRowIndex((array)($config['conditions'] ?? []), $conditionId) === null) {
            $conditionId = $this->allocateConfigId($config, 'condition');
            $rows = &$this->configRows($config, 'condition');
            $rows[] = [
                'id' => $conditionId,
                'department' => $departmentId,
                'lane' => 0,
                'product' => 0,
                'machine_type_id' => null,
                'condition_id' => null,
                'name' => 'Generated path condition',
                'description' => '',
                'expression' => $this->emptyV2Expression(),
            ];
            unset($rows);
        }

        $name = trim((string)($result['condition_name'] ?? ''));
        if ($name === '') {
            $name = 'Path: ' . $this->pathEditorAnswerSummary($answers);
        }
        $rows = &$this->configRows($config, 'condition');
        $index = $this->configRowIndex($rows, $conditionId);
        if ($index === null) {
            throw new \RuntimeException('Generated path condition could not be created.');
        }
        $rows[$index] = $this->mergeConfigEntityData('condition', $rows[$index], [
            'department' => $departmentId,
            'lane' => $scope['lane_id'] ?? 0,
            'product' => $scope['vehicle_type_id'] ?? 0,
            'machine_type_id' => $scope['machine_type_id'] ?? null,
            'name' => $name,
            'description' => 'Generated by Path Editor for ' . $this->pathEditorAnswerSummary($answers),
            'expression' => $this->pathEditorConditionExpression($answers),
        ]);
        $rows[$index]['generated_by'] = 'path_editor';
        $rows[$index]['path_key'] = $pathKey;
        unset($rows);

        return $conditionId;
    }

    /**
     * @param array<string,mixed> $config
     * @param array<string,mixed> $scope
     * @param array<string,mixed> $result
     */
    private function upsertPathEditorTask(int $departmentId, array &$config, string $pathKey, array $scope, array $result, int $conditionId, ?int $taskId, int $orderPriority): int
    {
        if ($taskId === null || $this->configRowIndex((array)($config['tasks'] ?? []), $taskId) === null) {
            $taskId = $this->allocateConfigId($config, 'task');
            $rows = &$this->configRows($config, 'task');
            $rows[] = [
                'id' => $taskId,
                'department' => $departmentId,
                'lane' => 0,
                'product' => 0,
                'machine_type_id' => null,
                'condition_id' => null,
                'gate_type' => selfserve_task_gate_type::CONDITION->value,
                'gate_ref_id' => $conditionId,
                'task' => 'Start machine',
                'description' => '',
                'order_priority' => $orderPriority,
                'services' => [],
                'buttons' => [],
                'dynamic_images_vehicle_type' => null,
            ];
            unset($rows);
        }

        $rows = &$this->configRows($config, 'task');
        $index = $this->configRowIndex($rows, $taskId);
        if ($index === null) {
            throw new \RuntimeException('Generated path task could not be created.');
        }
        $rows[$index] = $this->mergeConfigEntityData('task', $rows[$index], [
            'department' => $departmentId,
            'lane' => $scope['lane_id'] ?? 0,
            'product' => $scope['vehicle_type_id'] ?? 0,
            'machine_type_id' => $scope['machine_type_id'] ?? null,
            'condition_id' => $conditionId,
            'gate_type' => selfserve_task_gate_type::CONDITION->value,
            'gate_ref_id' => $conditionId,
            'task' => $result['task'],
            'description' => $result['description'],
            'order_priority' => $orderPriority,
            'services' => $result['services'],
            'buttons' => $result['buttons'],
            'dynamic_images_vehicle_type' => $result['dynamic_images_vehicle_type'],
        ]);
        $rows[$index]['condition_id'] = $conditionId;
        $rows[$index]['generated_by'] = 'path_editor';
        $rows[$index]['path_key'] = $pathKey;
        unset($rows);

        return $taskId;
    }

    /**
     * @param array<string,mixed> $existing
     * @param array<string,mixed> $data
     * @return array<int,int>
     */
    private function pathEditorExistingTaskIds(array $existing, array $data): array
    {
        $ids = [];
        foreach ([$existing['task_ids'] ?? null, $data['task_ids'] ?? null] as $taskIds) {
            if (!is_array($taskIds)) {
                continue;
            }
            foreach ($taskIds as $taskId) {
                $normalizedTaskId = $this->nullableInt($taskId);
                if ($normalizedTaskId !== null) {
                    $ids[] = $normalizedTaskId;
                }
            }
        }

        foreach ([
            $existing['task_id'] ?? null,
            $data['task_id'] ?? null,
            $data['existing_task_id'] ?? null,
        ] as $taskId) {
            $normalizedTaskId = $this->nullableInt($taskId);
            if ($normalizedTaskId !== null) {
                $ids[] = $normalizedTaskId;
            }
        }

        return array_values(array_unique(array_filter($ids, static fn(int $taskId): bool => $taskId > 0)));
    }

    /**
     * @param array<string,mixed> $config
     * @param array<int,int> $taskIds
     */
    private function pathEditorTaskBaseOrderPriority(array $config, array $taskIds): int
    {
        $taskRows = (array)($config['tasks'] ?? []);
        foreach ($taskIds as $taskId) {
            $index = $this->configRowIndex($taskRows, $taskId);
            if ($index !== null && is_array($taskRows[$index] ?? null)) {
                return (int)($taskRows[$index]['order_priority'] ?? 0);
            }
        }

        return $this->nextOrderPriority($taskRows);
    }

    /**
     * @param array<int,array<string,mixed>> $answers
     * @return array<string,mixed>
     */
    private function pathEditorConditionExpression(array $answers): array
    {
        return [
            'type' => 'group',
            'operator' => 'ALL',
            'children' => array_values(array_map(static fn(array $answer): array => [
                'type' => 'predicate',
                'subject_type' => 'question',
                'subject_id' => (int)($answer['question_id'] ?? 0),
                'operator' => ((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'IS_TRUE' : 'IS_FALSE',
            ], $answers)),
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $answers
     */
    private function pathEditorAnswerSummary(array $answers): string
    {
        $parts = [];
        foreach ($answers as $answer) {
            $parts[] = 'Q' . (int)($answer['question_id'] ?? 0) . '=' . (((bool)($answer['value'] ?? $answer['answer'] ?? false)) ? 'Yes' : 'No');
        }
        return implode(', ', $parts) ?: 'answers';
    }

    /**
     * @param array<string,mixed> $scope
     * @param array<int,array<string,mixed>> $answers
     */
    private function pathSignature(array $scope, array $answers): string
    {
        return hash('sha256', $this->stableJson([
            'scope' => $this->pathScopeKey($scope),
            'answers' => array_values(array_map(static fn(array $answer): array => [
                'question_id' => (int)($answer['question_id'] ?? 0),
                'answer' => (bool)($answer['answer'] ?? $answer['value'] ?? false),
            ], $answers)),
        ]));
    }

    /**
     * @param array<string,mixed> $result
     */
    private function pathEditorResultSignature(array $result, array $taskIds): string
    {
        $tasks = [];
        if ((bool)($result['machine_allowed'] ?? false)) {
            foreach (array_values((array)($result['tasks'] ?? [])) as $index => $task) {
                if (!is_array($task)) {
                    continue;
                }
                $tasks[] = [
                    'id' => (int)($taskIds[$index] ?? 0),
                    'label' => (string)($task['task'] ?? $task['label'] ?? ''),
                    'services' => $this->normalizeServiceList($task['services'] ?? []),
                    'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []),
                    'dynamic_images_vehicle_type' => $this->nullableInt($task['dynamic_images_vehicle_type'] ?? null),
                ];
            }
        }

        return hash('sha256', $this->stableJson([
            'allowed' => (bool)($result['machine_allowed'] ?? false),
            'services' => $this->normalizeServiceList($result['services'] ?? []),
            'tasks' => $tasks,
            'buttons' => $this->normalizeArrayPayload($result['buttons'] ?? []),
            'signals' => [],
        ]));
    }

    /**
     * @param array<string,mixed> $config
     * @param array<string,mixed> $data
     */
    private function createConfigEntity(int $departmentId, array &$config, string $entity, array $data): void
    {
        $id = $this->allocateConfigId($config, $entity);
        $row = match ($entity) {
            'question' => [
                'id' => $id,
                'department' => $departmentId,
                'lane' => 0,
                'product' => 0,
                'condition_id' => null,
                'question' => 'New question',
                'description' => '',
                'order_priority' => $this->nextOrderPriority((array)($config['questions'] ?? [])),
            ],
            'condition' => [
                'id' => $id,
                'department' => $departmentId,
                'lane' => 0,
                'product' => 0,
                'machine_type_id' => null,
                'condition_id' => null,
                'name' => 'New condition',
                'description' => '',
                'expression' => $this->emptyV2Expression(),
            ],
            'task' => [
                'id' => $id,
                'department' => $departmentId,
                'lane' => 0,
                'product' => 0,
                'machine_type_id' => null,
                'condition_id' => null,
                'gate_type' => selfserve_task_gate_type::ALWAYS->value,
                'gate_ref_id' => null,
                'task' => 'New task',
                'description' => '',
                'order_priority' => $this->nextOrderPriority((array)($config['tasks'] ?? [])),
                'services' => [],
                'buttons' => [],
                'dynamic_images_vehicle_type' => null,
            ],
            'action' => [
                'id' => $id,
                'department' => $departmentId,
                'lane' => 0,
                'product' => 0,
                'machine_type_id' => null,
                'condition_id' => null,
                'name' => 'Open lane entrance port',
                'description' => '',
                'event' => selfserve_studio_actions::EVENT_WASH_START_COMMAND,
                'wash_mode' => selfserve_studio_actions::MODE_BOTH,
                'operation' => selfserve_studio_actions::OP_OPEN_LANE_ENTRANCE_PORT,
                'relay_state' => null,
                'enabled' => true,
                'order_priority' => $this->nextOrderPriority((array)($config['actions'] ?? [])),
                'options' => [
                    'delay_ms' => 0,
                    'toggle_after_seconds' => 1,
                    'retry_count' => 0,
                    'failure_policy' => selfserve_studio_actions::FAILURE_CONTINUE,
                    'record_event' => true,
                ],
            ],
            default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
        };

        $row = $this->mergeConfigEntityData($entity, $row, $data);
        $rows = &$this->configRows($config, $entity);
        $rows[] = $row;
    }

    /**
     * @param array<string,mixed> $config
     * @param array<string,mixed> $data
     */
    private function updateConfigEntity(int $departmentId, array &$config, string $entity, int $id, array $data): void
    {
        unset($departmentId);
        $rows = &$this->configRows($config, $entity);
        $index = $this->configRowIndex($rows, $id);
        if ($index === null) {
            throw new \RuntimeException(ucfirst($entity) . ' is not available in the current draft.');
        }

        $rows[$index] = $this->mergeConfigEntityData($entity, $rows[$index], $data);
    }

    /**
     * @param array<string,mixed> $config
     */
    private function deleteConfigEntity(array &$config, string $entity, int $id): void
    {
        $rows = &$this->configRows($config, $entity);
        $rows = array_values(array_filter($rows, static fn(array $row): bool => (int)($row['id'] ?? 0) !== $id));

        if ($entity === 'question') {
            $conditionRows = &$this->configRows($config, 'condition');
            foreach ($conditionRows as &$condition) {
                if (is_array($condition) && is_array($condition['expression'] ?? null)) {
                    $condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'question', $id);
                }
            }
            unset($condition);
            $taskRows = &$this->configRows($config, 'task');
            foreach ($taskRows as &$task) {
                if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::QUESTION->value && (int)($task['gate_ref_id'] ?? 0) === $id) {
                    $task['gate_type'] = selfserve_task_gate_type::ALWAYS->value;
                    $task['gate_ref_id'] = null;
                    $task['condition_id'] = null;
                }
            }
            unset($task);
        }

        if ($entity === 'condition') {
            $conditionRows = &$this->configRows($config, 'condition');
            foreach ($conditionRows as &$condition) {
                if (!is_array($condition)) {
                    continue;
                }
                if ((int)($condition['condition_id'] ?? 0) === $id) {
                    $condition['condition_id'] = null;
                }
                if (is_array($condition['expression'] ?? null)) {
                    $condition['expression'] = $this->removeExpressionPredicate((array)$condition['expression'], 'condition', $id);
                }
            }
            unset($condition);
            $questionRows = &$this->configRows($config, 'question');
            foreach ($questionRows as &$question) {
                if (is_array($question) && (int)($question['condition_id'] ?? 0) === $id) {
                    $question['condition_id'] = null;
                }
            }
            unset($question);
            $taskRows = &$this->configRows($config, 'task');
            foreach ($taskRows as &$task) {
                if (is_array($task) && strtoupper((string)($task['gate_type'] ?? '')) === selfserve_task_gate_type::CONDITION->value && (int)($task['gate_ref_id'] ?? 0) === $id) {
                    $task['gate_type'] = selfserve_task_gate_type::ALWAYS->value;
                    $task['gate_ref_id'] = null;
                    $task['condition_id'] = null;
                }
            }
            unset($task);
            $actionRows = &$this->configRows($config, 'action');
            foreach ($actionRows as &$action) {
                if (is_array($action) && (int)($action['condition_id'] ?? 0) === $id) {
                    $action['condition_id'] = null;
                }
            }
            unset($action);
        }
    }

    /**
     * @param array<string,mixed> $config
     * @param array<int,array<string,mixed>> $items
     */
    private function applyConfigReorder(array &$config, string $entity, array $items): void
    {
        if (!in_array($entity, ['question', 'task', 'action'], true)) {
            throw new \RuntimeException('Only questions, tasks, and actions can be reordered.');
        }

        $priorities = [];
        foreach ($items as $index => $item) {
            if (is_array($item)) {
                $priorities[(int)($item['id'] ?? 0)] = (int)($item['order_priority'] ?? $index);
            }
        }

        $rows = &$this->configRows($config, $entity);
        foreach ($rows as &$row) {
            $id = (int)($row['id'] ?? 0);
            if (isset($priorities[$id])) {
                $row['order_priority'] = $priorities[$id];
            }
        }
        unset($row);
    }

    /**
     * @param array<string,mixed> $config
     */
    private function applyConfigConnection(int $departmentId, array &$config, string $source, string $target, bool $disconnect): void
    {
        [$sourceType, $sourceIdRaw] = $this->parseNodeId($source);
        [$targetType, $targetIdRaw] = $this->parseNodeId($target);
        if ($sourceType === '' || $targetType === '' || $sourceIdRaw === '') {
            throw new \RuntimeException('Invalid connection endpoints.');
        }

        if ($sourceType === 'task' && $targetType === 'binding') {
            $service = $this->serviceForBindingNode($departmentId, $target);
            if ($service === '') {
                throw new \RuntimeException('Relay binding has no service role to connect to the task.');
            }
            $this->updateConfigTaskServiceConnection($config, (int)$sourceIdRaw, $service, $disconnect);
            return;
        }
        if ($sourceType === 'binding' && $targetType === 'task') {
            $service = $this->serviceForBindingNode($departmentId, $source);
            if ($service === '') {
                throw new \RuntimeException('Relay binding has no service role to connect to the task.');
            }
            $this->updateConfigTaskServiceConnection($config, (int)$targetIdRaw, $service, $disconnect);
            return;
        }

        $sourceId = (int)$sourceIdRaw;
        $targetId = (int)$targetIdRaw;
        if ($sourceId <= 0 || $targetId <= 0) {
            throw new \RuntimeException('Invalid connection endpoints.');
        }

        if (in_array($sourceType, ['question', 'condition'], true) && $targetType === 'condition') {
            if ($sourceType === 'condition' && $sourceId === $targetId && !$disconnect) {
                throw new \RuntimeException('Condition expressions cannot reference themselves.');
            }
            $this->updateConditionExpressionConnection($config, $targetId, $sourceType, $sourceId, $disconnect);
            return;
        }

        if ($sourceType === 'condition' && $targetType === 'question') {
            $rows = &$this->configRows($config, 'question');
            $index = $this->configRowIndex($rows, $targetId);
            if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) {
                $rows[$index]['condition_id'] = $disconnect ? null : $sourceId;
            }
            return;
        }

        if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') {
            $rows = &$this->configRows($config, 'task');
            $index = $this->configRowIndex($rows, $targetId);
            if ($index === null) {
                return;
            }
            $currentType = strtoupper((string)($rows[$index]['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
            $currentRef = (int)($rows[$index]['gate_ref_id'] ?? 0);
            if ($disconnect && ($currentType !== strtoupper($sourceType) || $currentRef !== $sourceId)) {
                return;
            }
            $rows[$index]['gate_type'] = $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType);
            $rows[$index]['gate_ref_id'] = $disconnect ? null : $sourceId;
            $rows[$index]['condition_id'] = (!$disconnect && $sourceType === 'question') ? $sourceId : null;
            return;
        }

        if ($sourceType === 'condition' && $targetType === 'action') {
            $rows = &$this->configRows($config, 'action');
            $index = $this->configRowIndex($rows, $targetId);
            if ($index !== null && (!$disconnect || (int)($rows[$index]['condition_id'] ?? 0) === $sourceId)) {
                $rows[$index]['condition_id'] = $disconnect ? null : $sourceId;
            }
            return;
        }
    }

    /**
     * @param array<string,mixed> $config
     */
    private function updateConditionExpressionConnection(array &$config, int $conditionId, string $subjectType, int $subjectId, bool $disconnect): void
    {
        $rows = &$this->configRows($config, 'condition');
        $index = $this->configRowIndex($rows, $conditionId);
        if ($index === null) {
            throw new \RuntimeException('Condition is not available in the current draft.');
        }

        $expression = $this->normalizeExpressionNode($rows[$index]['expression'] ?? $this->emptyV2Expression());
        if (($expression['type'] ?? 'group') === 'predicate') {
            $expression = [
                'type' => 'group',
                'operator' => 'ALL',
                'children' => [$expression],
            ];
        } elseif (($expression['type'] ?? 'group') !== 'group') {
            $expression = [
                'type' => 'group',
                'operator' => 'ALL',
                'children' => [$expression],
            ];
        }
        if ($disconnect) {
            $rows[$index]['expression'] = $this->removeExpressionPredicate($expression, $subjectType, $subjectId);
            return;
        }

        if (!$this->expressionHasPredicate($expression, $subjectType, $subjectId)) {
            $expression['children'][] = [
                'type' => 'predicate',
                'subject_type' => $subjectType,
                'subject_id' => $subjectId,
                'operator' => 'IS_TRUE',
            ];
        }
        $rows[$index]['expression'] = $expression;
    }

    /**
     * @param array<string,mixed> $config
     */
    private function updateConfigTaskServiceConnection(array &$config, int $taskId, string $service, bool $disconnect): void
    {
        if ($taskId <= 0 || $service === '') {
            return;
        }

        $rows = &$this->configRows($config, 'task');
        $index = $this->configRowIndex($rows, $taskId);
        if ($index === null) {
            throw new \RuntimeException('Task is not available in the current draft.');
        }

        $services = array_fill_keys($this->normalizeServiceList($rows[$index]['services'] ?? []), true);
        if ($disconnect) {
            unset($services[$service]);
        } else {
            $services[$service] = true;
        }
        $rows[$index]['services'] = array_keys($services);
    }

    /**
     * @param array<string,mixed> $config
     * @return array<int,array<string,mixed>>
     */
    private function &configRows(array &$config, string $entity): array
    {
        $key = match ($entity) {
            'question' => 'questions',
            'condition' => 'conditions',
            'task' => 'tasks',
            'action' => 'actions',
            default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
        };
        if (!isset($config[$key]) || !is_array($config[$key])) {
            $config[$key] = [];
        }
        return $config[$key];
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     */
    private function configRowIndex(array $rows, int $id): ?int
    {
        foreach ($rows as $index => $row) {
            if ((int)($row['id'] ?? 0) === $id) {
                return (int)$index;
            }
        }
        return null;
    }

    /**
     * @param array<string,mixed> $config
     */
    private function allocateConfigId(array &$config, string $entity): int
    {
        $name = match ($entity) {
            'question', 'condition', 'task', 'action' => $entity,
            default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
        };
        if (!isset($config['v2_meta']) || !is_array($config['v2_meta'])) {
            $config['v2_meta'] = [];
        }
        if (!isset($config['v2_meta']['next_ids']) || !is_array($config['v2_meta']['next_ids'])) {
            $config['v2_meta']['next_ids'] = [];
        }

        $rows = &$this->configRows($config, $entity);
        $maxId = 0;
        foreach ($rows as $row) {
            $maxId = max($maxId, (int)($row['id'] ?? 0));
        }

        $nextId = max((int)($config['v2_meta']['next_ids'][$name] ?? 0), $maxId + 1);
        $config['v2_meta']['next_ids'][$name] = $nextId + 1;
        return $nextId;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     */
    private function nextOrderPriority(array $rows): int
    {
        $max = 0;
        foreach ($rows as $row) {
            $max = max($max, (int)($row['order_priority'] ?? 0));
        }
        return $max + 10;
    }

    /**
     * @param array<string,mixed> $row
     * @param array<string,mixed> $data
     * @return array<string,mixed>
     */
    private function mergeConfigEntityData(string $entity, array $row, array $data): array
    {
        if (array_key_exists('label', $data)) {
            if ($entity === 'question' && !array_key_exists('question', $data)) {
                $data['question'] = $data['label'];
            } elseif ($entity === 'condition' && !array_key_exists('name', $data)) {
                $data['name'] = $data['label'];
            } elseif ($entity === 'task' && !array_key_exists('task', $data)) {
                $data['task'] = $data['label'];
            } elseif ($entity === 'action' && !array_key_exists('name', $data)) {
                $data['name'] = $data['label'];
            }
        }

        $fields = match ($entity) {
            'question' => ['department', 'lane', 'product', 'condition_id', 'question', 'description', 'order_priority'],
            'condition' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'expression'],
            'task' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'],
            'action' => ['department', 'lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description', 'event', 'wash_mode', 'operation', 'relay_state', 'enabled', 'order_priority', 'options'],
            default => throw new \RuntimeException('Unsupported studio entity: ' . $entity),
        };

        foreach ($fields as $field) {
            if (!array_key_exists($field, $data)) {
                continue;
            }
            $row[$field] = $this->normalizeConfigField($field, $data[$field]);
        }

        if ($entity === 'condition' && !is_array($row['expression'] ?? null)) {
            $row['expression'] = $this->emptyV2Expression();
        }
        if ($entity === 'task') {
            $row['gate_type'] = $this->normalizeGateType((string)($row['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
            if ($row['gate_type'] === selfserve_task_gate_type::ALWAYS->value) {
                $row['gate_ref_id'] = null;
                $row['condition_id'] = null;
            }
        }
        if ($entity === 'action') {
            $row = selfserve_studio_actions::normalize($row);
        }

        return $row;
    }

    private function normalizeConfigField(string $field, mixed $value): mixed
    {
        if ($field === 'expression') {
            return $this->normalizeExpressionNode($value);
        }
        if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type', 'toggle_after_seconds'], true)) {
            return $this->nullableInt($value);
        }
        if (in_array($field, ['department', 'lane', 'product', 'order_priority', 'delay_ms', 'retry_count'], true)) {
            return (int)$value;
        }
        if (in_array($field, ['enabled', 'relay_state', 'record_event'], true)) {
            return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
        }
        if ($field === 'services') {
            return $this->normalizeServiceList($value);
        }
        if ($field === 'buttons') {
            return $this->normalizeArrayPayload($value);
        }
        if ($field === 'options') {
            return is_array($value) ? (array)$value : [];
        }
        if ($field === 'gate_type') {
            return $this->normalizeGateType((string)$value);
        }
        return is_array($value) ? $value : (string)$value;
    }

    /**
     * @param array<string,mixed> $operation
     */
    private function applyOperation(int $departmentId, array $operation, array $permissions = []): void
    {
        $action = strtolower((string)($operation['action'] ?? ''));
        $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
        $data = is_array($operation['data'] ?? null) ? (array)$operation['data'] : [];
        $id = (int)($operation['id'] ?? $data['id'] ?? 0);

        if ($action === 'connect') {
            $this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), false);
            return;
        }
        if ($action === 'disconnect') {
            $this->applyConnection($departmentId, (string)($operation['source'] ?? ''), (string)($operation['target'] ?? ''), true);
            return;
        }
        if ($action === 'reorder') {
            $this->applyReorder($entity, (array)($operation['items'] ?? []));
            return;
        }
        if ($entity === '') {
            throw new \RuntimeException('Studio graph operation is missing entity.');
        }
        if ($entity === 'lane') {
            $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions);
            return;
        }

        if ($action === 'create') {
            $this->createEntity($departmentId, $entity, $data);
            return;
        }
        if ($id <= 0) {
            throw new \RuntimeException('Studio graph operation is missing id.');
        }
        if ($action === 'update') {
            $this->updateEntity($departmentId, $entity, $id, $data);
            return;
        }
        if ($action === 'delete') {
            $this->softDeleteEntity($departmentId, $entity, $id);
            return;
        }

        throw new \RuntimeException('Unsupported studio graph operation: ' . $action);
    }

    /**
     * @param array<string,mixed> $data
     */
    private function applyLaneOperation(int $departmentId, string $action, int $id, array $data, array $permissions = []): void
    {
        if (!$this->tableExists('department_lanes')) {
            throw new \RuntimeException('Department lanes are not available.');
        }

        $this->assertLaneOperationAuthorized($action, $data, $permissions);

        if ($action === 'create') {
            $this->createLane($departmentId, $data);
            return;
        }

        if ($id <= 0) {
            throw new \RuntimeException('Studio lane operation is missing id.');
        }

        if (!$this->laneBelongsToDepartment($id, $departmentId)) {
            throw new \RuntimeException('Lane is not available in the selected department.');
        }

        if ($action === 'update') {
            $this->updateLane($departmentId, $id, $data);
            return;
        }

        if ($action === 'delete') {
            $this->softDeleteLane($departmentId, $id);
            return;
        }

        throw new \RuntimeException('Unsupported studio lane operation: ' . $action);
    }

    /**
     * @param array<string,mixed> $data
     * @param array<string,bool> $permissions
     */
    private function assertLaneOperationAuthorized(string $action, array $data, array $permissions): void
    {
        if ($action === 'create' && !($permissions['can_add_department_lane'] ?? false)) {
            throw new \RuntimeException('Missing permission: add_department_lane.');
        }
        if (in_array($action, ['update', 'delete'], true) && !($permissions['can_edit_department_lane'] ?? false)) {
            throw new \RuntimeException('Missing permission: edit_department_lane.');
        }

        if (!in_array($action, ['create', 'update'], true)) {
            return;
        }

        $relayFields = [
            'relay_in_id',
            'relay_out_id',
            'relay_machine_id',
            'relay_machine_program_picker_id',
            'relay_machine_cleaner_id',
        ];
        foreach ($relayFields as $field) {
            if (array_key_exists($field, $data) && !($permissions['modules_shelly_config'] ?? false)) {
                throw new \RuntimeException('Missing permission: modules_shelly_config.');
            }
        }
    }

    /**
     * @param array<string,mixed> $data
     */
    private function createLane(int $departmentId, array $data): void
    {
        $fields = [
            'department' => $departmentId,
            'name' => $this->normalizeLaneName((string)($data['name'] ?? $data['label'] ?? 'New lane')),
        ];

        foreach ($this->laneOptionalFields() as $field) {
            if (array_key_exists($field, $data)) {
                $fields[$field] = $this->normalizeLaneField($field, $data[$field]);
            }
        }

        $availableColumns = $this->tableColumns('department_lanes');
        $fields = array_filter(
            $fields,
            static fn(mixed $value, string $field): bool => in_array($field, $availableColumns, true),
            ARRAY_FILTER_USE_BOTH
        );

        $columns = array_keys($fields);
        $placeholders = array_map(static fn(string $field): string => ':' . $field, $columns);
        $params = [];
        foreach ($fields as $field => $value) {
            $params[':' . $field] = $value;
        }

        db::getPDO()->prepare(
            'INSERT INTO department_lanes (`' . implode('`, `', $columns) . '`) VALUES (' . implode(', ', $placeholders) . ')'
        )->execute($params);
    }

    /**
     * @param array<string,mixed> $data
     */
    private function updateLane(int $departmentId, int $id, array $data): void
    {
        unset($departmentId);
        $availableColumns = $this->tableColumns('department_lanes');
        $updates = [];
        $params = [':id' => $id];
        $wasSelfServeEnabled = null;

        if (array_key_exists('selfserve_enabled', $data) && in_array('selfserve_enabled', $availableColumns, true)) {
            $statement = db::getPDO()->prepare(
                'SELECT selfserve_enabled FROM department_lanes WHERE id = :id AND deleted_at IS NULL'
            );
            $statement->execute([':id' => $id]);
            $wasSelfServeEnabled = ((int)($statement->fetch(\PDO::FETCH_ASSOC)['selfserve_enabled'] ?? 1)) === 1;
        }

        $fields = ['name', ...$this->laneOptionalFields()];
        foreach ($fields as $field) {
            if (!array_key_exists($field, $data) || !in_array($field, $availableColumns, true)) {
                continue;
            }
            $updates[] = '`' . $field . '` = :' . $field;
            $params[':' . $field] = $field === 'name'
                ? $this->normalizeLaneName((string)$data[$field])
                : $this->normalizeLaneField($field, $data[$field]);
        }

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

        db::getPDO()->prepare(
            'UPDATE department_lanes SET ' . implode(', ', $updates) . ' WHERE id = :id AND deleted_at IS NULL'
        )->execute($params);

        if (
            $wasSelfServeEnabled === true
            && array_key_exists(':selfserve_enabled', $params)
            && (int)$params[':selfserve_enabled'] === 0
        ) {
            \objects\department_lanes_o::disableSelfServeRelaysBestEffort($id);
        }
    }

    private function softDeleteLane(int $departmentId, int $id): void
    {
        unset($departmentId);
        db::getPDO()->prepare(
            'UPDATE department_lanes SET deleted_at = NOW() WHERE id = :id AND deleted_at IS NULL'
        )->execute([':id' => $id]);
    }

    /**
     * @return array<int,string>
     */
    private function laneOptionalFields(): array
    {
        return [
            'relay_in_id',
            'relay_out_id',
            'relay_machine_id',
            'relay_machine_program_picker_id',
            'relay_machine_cleaner_id',
            'dynamic_image_id',
            'machine_type_id',
            'selfserve_enabled',
        ];
    }

    private function normalizeLaneField(string $field, mixed $value): mixed
    {
        if ($field === 'selfserve_enabled') {
            return \objects\department_lanes_o::normalizeSelfServeEnabledValue($value) ? 1 : 0;
        }

        if (in_array($field, ['dynamic_image_id', 'machine_type_id'], true)) {
            return $this->nullableInt($value);
        }

        $normalized = trim((string)($value ?? ''));
        if ($normalized === '' || $normalized === '0' || strtolower($normalized) === 'null') {
            return null;
        }

        return $normalized;
    }

    private function normalizeLaneName(string $name): string
    {
        $normalized = trim($name);
        if ($normalized === '') {
            throw new \RuntimeException('Lane name is required.');
        }

        return $normalized;
    }

    /**
     * @param array<string,mixed> $data
     */
    private function createEntity(int $departmentId, string $entity, array $data): void
    {
        $pdo = db::getPDO();
        if ($entity === 'question') {
            $pdo->prepare(
                "INSERT INTO department_selfserve_questions (department, lane, product, condition_id, question, description, order_priority)
                 VALUES (:department, :lane, :product, :condition_id, :question, :description, :order_priority)"
            )->execute([
                ':department' => $departmentId,
                ':lane' => (int)($data['lane'] ?? 0),
                ':product' => (int)($data['product'] ?? 0),
                ':condition_id' => $this->nullableInt($data['condition_id'] ?? null),
                ':question' => (string)($data['question'] ?? $data['label'] ?? 'New question'),
                ':description' => (string)($data['description'] ?? ''),
                ':order_priority' => (int)($data['order_priority'] ?? 0),
            ]);
            return;
        }
        if ($entity === 'condition') {
            $pdo->prepare(
                "INSERT INTO department_selfserve_conditions (department, lane, product, machine_type_id, condition_id, name, description)
                 VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :name, :description)"
            )->execute([
                ':department' => $departmentId,
                ':lane' => (int)($data['lane'] ?? 0),
                ':product' => (int)($data['product'] ?? 0),
                ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null),
                ':condition_id' => $this->nullableInt($data['condition_id'] ?? null),
                ':name' => (string)($data['name'] ?? $data['label'] ?? 'New condition'),
                ':description' => (string)($data['description'] ?? ''),
            ]);
            return;
        }
        if ($entity === 'rule') {
            $conditionId = (int)($data['condition_id'] ?? 0);
            if (!$this->conditionBelongsToDepartment($conditionId, $departmentId)) {
                throw new \RuntimeException('Rule condition_id is not available in the selected department.');
            }
            $pdo->prepare(
                "INSERT INTO department_selfserve_condition_rules (condition_id, type, object_type, object_id, name, description)
                 VALUES (:condition_id, :type, :object_type, :object_id, :name, :description)"
            )->execute([
                ':condition_id' => $conditionId,
                ':type' => (string)($data['type'] ?? 'IS_TRUE'),
                ':object_type' => (string)($data['object_type'] ?? 'question'),
                ':object_id' => (int)($data['object_id'] ?? 0),
                ':name' => (string)($data['name'] ?? $data['label'] ?? 'New rule'),
                ':description' => (string)($data['description'] ?? ''),
            ]);
            return;
        }
        if ($entity === 'task') {
            $gateType = $this->normalizeGateType((string)($data['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value));
            $gateRefId = $gateType === selfserve_task_gate_type::ALWAYS->value ? null : $this->nullableInt($data['gate_ref_id'] ?? null);
            $pdo->prepare(
                "INSERT INTO department_selfserve_tasks (department, lane, product, machine_type_id, condition_id, gate_type, gate_ref_id, task, description, order_priority, services, buttons, dynamic_images_vehicle_type)
                 VALUES (:department, :lane, :product, :machine_type_id, :condition_id, :gate_type, :gate_ref_id, :task, :description, :order_priority, :services, :buttons, :dynamic_images_vehicle_type)"
            )->execute([
                ':department' => $departmentId,
                ':lane' => (int)($data['lane'] ?? 0),
                ':product' => (int)($data['product'] ?? 0),
                ':machine_type_id' => $this->nullableInt($data['machine_type_id'] ?? null),
                ':condition_id' => $gateType === selfserve_task_gate_type::QUESTION->value ? $gateRefId : null,
                ':gate_type' => $gateType,
                ':gate_ref_id' => $gateRefId,
                ':task' => (string)($data['task'] ?? $data['label'] ?? 'New task'),
                ':description' => (string)($data['description'] ?? ''),
                ':order_priority' => (int)($data['order_priority'] ?? 0),
                ':services' => $this->jsonArray($data['services'] ?? []),
                ':buttons' => $this->jsonArray($data['buttons'] ?? []),
                ':dynamic_images_vehicle_type' => $this->nullableInt($data['dynamic_images_vehicle_type'] ?? null),
            ]);
            return;
        }

        throw new \RuntimeException('Unsupported studio entity: ' . $entity);
    }

    /**
     * @param array<string,mixed> $data
     */
    private function updateEntity(int $departmentId, string $entity, int $id, array $data): void
    {
        $map = [
            'question' => [
                'table' => 'department_selfserve_questions',
                'fields' => ['lane', 'product', 'condition_id', 'question', 'description', 'order_priority'],
                'department' => 'department',
            ],
            'condition' => [
                'table' => 'department_selfserve_conditions',
                'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'name', 'description'],
                'department' => 'department',
            ],
            'rule' => [
                'table' => 'department_selfserve_condition_rules',
                'fields' => ['condition_id', 'type', 'object_type', 'object_id', 'name', 'description'],
                'department' => null,
            ],
            'task' => [
                'table' => 'department_selfserve_tasks',
                'fields' => ['lane', 'product', 'machine_type_id', 'condition_id', 'gate_type', 'gate_ref_id', 'task', 'description', 'order_priority', 'services', 'buttons', 'dynamic_images_vehicle_type'],
                'department' => 'department',
            ],
        ];
        if (!isset($map[$entity])) {
            throw new \RuntimeException('Unsupported studio entity: ' . $entity);
        }
        if ($entity === 'rule' && array_key_exists('condition_id', $data) && !$this->conditionBelongsToDepartment((int)$data['condition_id'], $departmentId)) {
            throw new \RuntimeException('Rule condition_id is not available in the selected department.');
        }

        $updates = [];
        $params = [
            ':id' => $id,
        ];
        foreach ($map[$entity]['fields'] as $field) {
            if (!array_key_exists($field, $data)) {
                continue;
            }
            $updates[] = "`$field` = :$field";
            $value = $data[$field];
            if (in_array($field, ['condition_id', 'machine_type_id', 'gate_ref_id', 'dynamic_images_vehicle_type'], true)) {
                $value = $this->nullableInt($value);
            } elseif (in_array($field, ['services', 'buttons'], true)) {
                $value = $this->jsonArray($value);
            } elseif ($field === 'gate_type') {
                $value = $this->normalizeGateType((string)$value);
            }
            $params[':' . $field] = $value;
        }

        if (isset($data['label'])) {
            if ($entity === 'question' && !isset($data['question'])) {
                $updates[] = '`question` = :label';
                $params[':label'] = (string)$data['label'];
            } elseif ($entity === 'condition' && !isset($data['name'])) {
                $updates[] = '`name` = :label';
                $params[':label'] = (string)$data['label'];
            } elseif ($entity === 'task' && !isset($data['task'])) {
                $updates[] = '`task` = :label';
                $params[':label'] = (string)$data['label'];
            }
        }

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

        $where = 'id = :id';
        if ($entity === 'rule') {
            $where .= " AND condition_id IN (
                SELECT id
                FROM department_selfserve_conditions
                WHERE department IN (0, :department)
                  AND deleted_at IS NULL
            )";
            $params[':department'] = $departmentId;
        } elseif ($map[$entity]['department'] !== null) {
            $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)';
            $params[':department'] = $departmentId;
        }
        db::getPDO()->prepare(
            'UPDATE `' . $map[$entity]['table'] . '` SET ' . implode(', ', $updates) . ' WHERE ' . $where
        )->execute($params);
    }

    private function softDeleteEntity(int $departmentId, string $entity, int $id): void
    {
        $map = [
            'question' => ['table' => 'department_selfserve_questions', 'department' => 'department'],
            'condition' => ['table' => 'department_selfserve_conditions', 'department' => 'department'],
            'rule' => ['table' => 'department_selfserve_condition_rules', 'department' => null],
            'task' => ['table' => 'department_selfserve_tasks', 'department' => 'department'],
        ];
        if (!isset($map[$entity])) {
            throw new \RuntimeException('Unsupported studio entity: ' . $entity);
        }

        $params = [
            ':id' => $id,
        ];
        $where = 'id = :id';
        if ($entity === 'rule') {
            $where .= " AND condition_id IN (
                SELECT id
                FROM department_selfserve_conditions
                WHERE department IN (0, :department)
                  AND deleted_at IS NULL
            )";
            $params[':department'] = $departmentId;
        } elseif ($map[$entity]['department'] !== null) {
            $where .= ' AND `' . $map[$entity]['department'] . '` IN (0, :department)';
            $params[':department'] = $departmentId;
        }
        db::getPDO()->prepare(
            'UPDATE `' . $map[$entity]['table'] . '` SET deleted_at = NOW() WHERE ' . $where
        )->execute($params);
    }

    /**
     * @param array<int,array<string,mixed>> $items
     */
    private function applyReorder(string $entity, array $items): void
    {
        $table = match ($entity) {
            'question' => 'department_selfserve_questions',
            'task' => 'department_selfserve_tasks',
            default => null,
        };
        if ($table === null) {
            throw new \RuntimeException('Only questions and tasks can be reordered.');
        }

        $statement = db::getPDO()->prepare('UPDATE `' . $table . '` SET order_priority = :order_priority WHERE id = :id');
        foreach ($items as $index => $item) {
            if (!is_array($item)) {
                continue;
            }
            $statement->execute([
                ':id' => (int)($item['id'] ?? 0),
                ':order_priority' => (int)($item['order_priority'] ?? $index),
            ]);
        }
    }

    private function applyConnection(int $departmentId, string $source, string $target, bool $disconnect): void
    {
        [$sourceType, $sourceId] = $this->parseNodeId($source);
        [$targetType, $targetId] = $this->parseNodeId($target);
        if ($sourceType === '' || $targetType === '' || $sourceId === '') {
            throw new \RuntimeException('Invalid connection endpoints.');
        }

        if ($sourceType === 'task' && $targetType === 'binding') {
            $service = $this->serviceForBindingNode($departmentId, $target);
            if ($service === '') {
                throw new \RuntimeException('Relay binding has no service role to connect to the task.');
            }
            $this->updateTaskServiceConnection($departmentId, (int)$sourceId, $service, $disconnect);
            return;
        }
        if ($sourceType === 'binding' && $targetType === 'task') {
            $service = $this->serviceForBindingNode($departmentId, $source);
            if ($service === '') {
                throw new \RuntimeException('Relay binding has no service role to connect to the task.');
            }
            $this->updateTaskServiceConnection($departmentId, (int)$targetId, $service, $disconnect);
            return;
        }

        if ($sourceType === 'condition' && $targetType === 'question') {
            db::getPDO()->prepare('UPDATE department_selfserve_questions SET condition_id = :condition_id WHERE id = :id')->execute([
                ':condition_id' => $disconnect ? null : (int)$sourceId,
                ':id' => (int)$targetId,
            ]);
            return;
        }
        if ($sourceType === 'condition' && $targetType === 'condition') {
            db::getPDO()->prepare('UPDATE department_selfserve_conditions SET condition_id = :condition_id WHERE id = :id')->execute([
                ':condition_id' => $disconnect ? null : (int)$sourceId,
                ':id' => (int)$targetId,
            ]);
            return;
        }
        if (in_array($sourceType, ['condition', 'question'], true) && $targetType === 'task') {
            db::getPDO()->prepare('UPDATE department_selfserve_tasks SET gate_type = :gate_type, gate_ref_id = :gate_ref_id, condition_id = :legacy_question_id WHERE id = :id')->execute([
                ':gate_type' => $disconnect ? selfserve_task_gate_type::ALWAYS->value : strtoupper($sourceType),
                ':gate_ref_id' => $disconnect ? null : (int)$sourceId,
                ':legacy_question_id' => (!$disconnect && $sourceType === 'question') ? (int)$sourceId : null,
                ':id' => (int)$targetId,
            ]);
            return;
        }
        if ($sourceType === 'condition' && $targetType === 'rule') {
            db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET condition_id = :condition_id WHERE id = :id')->execute([
                ':condition_id' => $disconnect ? 0 : (int)$sourceId,
                ':id' => (int)$targetId,
            ]);
            return;
        }
        if (in_array($sourceType, ['question', 'condition', 'task'], true) && $targetType === 'rule') {
            db::getPDO()->prepare('UPDATE department_selfserve_condition_rules SET object_type = :object_type, object_id = :object_id WHERE id = :id')->execute([
                ':object_type' => $disconnect ? '' : $sourceType,
                ':object_id' => $disconnect ? 0 : (int)$sourceId,
                ':id' => (int)$targetId,
            ]);
            return;
        }
    }

    /**
     * @param array<string,mixed> $row
     * @param array<string,mixed> $lookups
     * @return array<string,mixed>
     */
    private function scopeForRow(array $row, array $lookups): array
    {
        return [
            'department' => $this->labelFor('departments', $row['department'] ?? null, $lookups),
            'lane' => $this->labelFor('lanes', $row['lane'] ?? null, $lookups),
            'product' => $this->labelFor('products', $row['product'] ?? null, $lookups),
            'machine_type' => $this->labelFor('machine_types', $row['machine_type_id'] ?? null, $lookups),
        ];
    }

    /**
     * @param array<string,mixed> $row
     */
    private function scopeLabel(array $row, array $lookups): string
    {
        $parts = [];
        foreach (['lane' => 'lanes', 'product' => 'products', 'machine_type_id' => 'machine_types'] as $field => $lookupType) {
            $value = $this->nullableInt($row[$field] ?? null);
            if ($value !== null) {
                $parts[] = $this->labelFor($lookupType, $value, $lookups);
            }
        }

        return $parts === [] ? 'Shared scope' : implode(' / ', $parts);
    }

    /**
     * @param array<string,mixed> $row
     */
    private function ruleSubtitle(array $row, array $lookups): string
    {
        $objectType = strtolower((string)($row['object_type'] ?? 'object'));
        $objectId = (int)($row['object_id'] ?? 0);
        $lookupType = $objectType . 's';
        $label = $objectId > 0 ? $this->labelFor($lookupType, $objectId, $lookups) : 'Unbound object';
        return strtoupper((string)($row['type'] ?? 'RULE')) . ' ' . $label;
    }

    /**
     * @param array<string,mixed> $expression
     */
    private function expressionSummary(array $expression): 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'));
            $subjectLabel = match ($subjectType) {
                'question' => 'Question ' . $subjectId,
                'condition' => 'Condition ' . $subjectId,
                default => 'Unknown subject',
            };
            return $subjectLabel . ' ' . strtolower(str_replace('_', ' ', $operator));
        }
        if (in_array($type, ['branch', 'if', 'if_else'], true)) {
            $branches = is_array($expression['branches'] ?? null) ? array_values((array)$expression['branches']) : [];
            if ($branches === []) {
                return 'No if/else clauses';
            }

            $parts = [];
            foreach (array_slice($branches, 0, 3) as $index => $branch) {
                if (!is_array($branch)) {
                    continue;
                }
                $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if')));
                $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true);
                $label = $isElse ? 'Else' : ($index === 0 ? 'If' : 'Else if');
                $when = !$isElse && is_array($branch['when'] ?? null) ? $this->expressionSummary((array)$branch['when']) : '';
                $then = is_array($branch['then'] ?? null) ? $this->expressionSummary((array)$branch['then']) : 'No result';
                $parts[] = trim($label . ($when === '' ? '' : ' ' . $when) . ' then ' . $then);
            }
            if (count($branches) > 3) {
                $parts[] = '+' . (count($branches) - 3) . ' more';
            }

            return implode('; ', $parts);
        }
        if ($type === 'case') {
            $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
            $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
            $subjectLabel = match ($subjectType) {
                'question' => 'Question ' . $subjectId,
                'condition' => 'Condition ' . $subjectId,
                default => 'Unknown subject',
            };
            $cases = is_array($expression['cases'] ?? null) ? array_values((array)$expression['cases']) : [];
            if ($cases === []) {
                return 'Case ' . $subjectLabel . ': no clauses';
            }

            $parts = [];
            foreach (array_slice($cases, 0, 3) as $case) {
                if (!is_array($case)) {
                    continue;
                }
                $value = $this->caseValueLabel($case['value'] ?? null);
                $then = is_array($case['then'] ?? null) ? $this->expressionSummary((array)$case['then']) : 'No result';
                $parts[] = $value . ' then ' . $then;
            }
            if (count($cases) > 3) {
                $parts[] = '+' . (count($cases) - 3) . ' more';
            }

            return 'Case ' . $subjectLabel . ': ' . implode('; ', $parts);
        }

        $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
        if (!in_array($operator, ['ALL', 'ANY'], true)) {
            $operator = '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->expressionSummary((array)$child);
            }
        }
        if (count($children) > 3) {
            $parts[] = '+' . (count($children) - 3) . ' more';
        }

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

    /**
     * @param array<int,array<string,mixed>> $edges
     * @param array<string,mixed> $expression
     */
    private function appendExpressionEdges(array &$edges, string $targetNodeId, int $ownerConditionId, array $expression, string $path = '0'): void
    {
        $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);
            if (!in_array($subjectType, ['question', 'condition'], true) || $subjectId <= 0) {
                return;
            }
            $edge = $this->edge(
                'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path), 0, 8),
                $subjectType . ':' . $subjectId,
                $targetNodeId,
                'condition_expression',
                strtoupper((string)($expression['operator'] ?? 'IS_TRUE'))
            );
            $edge['data']['subject_type'] = $subjectType;
            $edge['data']['subject_id'] = $subjectId;
            $edge['data']['condition_id'] = $ownerConditionId;
            $edges[] = $edge;
            return;
        }
        if (in_array($type, ['branch', 'if', 'if_else'], true)) {
            foreach ((array)($expression['branches'] ?? []) as $index => $branch) {
                if (!is_array($branch)) {
                    continue;
                }
                if (is_array($branch['when'] ?? null)) {
                    $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['when'], $path . '.b' . $index . '.when');
                }
                if (is_array($branch['then'] ?? null)) {
                    $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$branch['then'], $path . '.b' . $index . '.then');
                }
            }
            if (is_array($expression['default'] ?? null)) {
                $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default');
            }
            return;
        }
        if ($type === 'case') {
            $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
            $subjectId = (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0);
            if (in_array($subjectType, ['question', 'condition'], true) && $subjectId > 0) {
                $edge = $this->edge(
                    'expression:' . $ownerConditionId . ':' . $subjectType . ':' . $subjectId . ':' . substr(md5($path . '.case'), 0, 8),
                    $subjectType . ':' . $subjectId,
                    $targetNodeId,
                    'condition_expression',
                    'CASE'
                );
                $edge['data']['subject_type'] = $subjectType;
                $edge['data']['subject_id'] = $subjectId;
                $edge['data']['condition_id'] = $ownerConditionId;
                $edges[] = $edge;
            }
            foreach ((array)($expression['cases'] ?? []) as $index => $case) {
                if (is_array($case) && is_array($case['then'] ?? null)) {
                    $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$case['then'], $path . '.c' . $index . '.then');
                }
            }
            if (is_array($expression['default'] ?? null)) {
                $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$expression['default'], $path . '.default');
            }
            return;
        }

        $children = is_array($expression['children'] ?? null) ? array_values((array)$expression['children']) : [];
        foreach ($children as $index => $child) {
            if (is_array($child)) {
                $this->appendExpressionEdges($edges, $targetNodeId, $ownerConditionId, (array)$child, $path . '.' . $index);
            }
        }
    }

    private function normalizeExpressionNode(mixed $expression): array
    {
        if (!is_array($expression)) {
            return $this->emptyV2Expression();
        }

        $type = strtolower((string)($expression['type'] ?? $expression['kind'] ?? 'group'));
        if ($type === 'predicate') {
            $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
            if (!in_array($subjectType, ['question', 'condition'], true)) {
                $subjectType = 'question';
            }
            $operator = strtoupper((string)($expression['operator'] ?? $expression['rule_type'] ?? 'IS_TRUE'));
            if (!in_array($operator, ['IS_TRUE', 'IS_FALSE', 'IS_SET', 'IS_TRUE_OR_NOT_SET', 'IS_FALSE_OR_NOT_SET'], true)) {
                $operator = 'IS_TRUE';
            }
            return [
                'type' => 'predicate',
                'subject_type' => $subjectType,
                'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0),
                'operator' => $operator,
            ];
        }
        if (in_array($type, ['branch', 'if', 'if_else'], true)) {
            $branches = [];
            foreach ((array)($expression['branches'] ?? []) as $index => $branch) {
                if (!is_array($branch)) {
                    continue;
                }
                $kind = strtolower((string)($branch['kind'] ?? $branch['type'] ?? $branch['operator'] ?? ($index === 0 ? 'if' : 'else_if')));
                $isElse = (bool)($branch['else'] ?? false) || in_array($kind, ['else', 'default'], true);
                $normalized = [
                    'kind' => $isElse ? 'else' : ($index === 0 ? 'if' : 'else_if'),
                    'then' => $this->normalizeExpressionNode($branch['then'] ?? $this->emptyV2Expression()),
                ];
                if ($isElse) {
                    $normalized['else'] = true;
                } else {
                    $normalized['when'] = $this->normalizeExpressionNode($branch['when'] ?? $this->emptyV2Expression());
                }
                $branches[] = $normalized;
            }

            $normalizedExpression = [
                'type' => 'branch',
                'operator' => 'IF_ELSE',
                'branches' => $branches,
            ];
            if (is_array($expression['default'] ?? null)) {
                $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']);
            }
            return $normalizedExpression;
        }
        if ($type === 'case') {
            $subjectType = strtolower((string)($expression['subject_type'] ?? $expression['object_type'] ?? ''));
            if (!in_array($subjectType, ['question', 'condition'], true)) {
                $subjectType = 'question';
            }
            $cases = [];
            foreach ((array)($expression['cases'] ?? []) as $case) {
                if (!is_array($case)) {
                    continue;
                }
                $cases[] = [
                    'value' => $case['value'] ?? null,
                    'then' => $this->normalizeExpressionNode($case['then'] ?? $this->emptyV2Expression()),
                ];
            }

            $normalizedExpression = [
                'type' => 'case',
                'operator' => 'CASE',
                'subject_type' => $subjectType,
                'subject_id' => (int)($expression['subject_id'] ?? $expression['object_id'] ?? 0),
                'cases' => $cases,
            ];
            if (is_array($expression['default'] ?? null)) {
                $normalizedExpression['default'] = $this->normalizeExpressionNode((array)$expression['default']);
            }
            return $normalizedExpression;
        }

        $operator = strtoupper((string)($expression['operator'] ?? $expression['mode'] ?? 'ALL'));
        if (!in_array($operator, ['ALL', 'ANY'], true)) {
            $operator = 'ALL';
        }
        $children = [];
        foreach ((array)($expression['children'] ?? []) as $child) {
            if (is_array($child)) {
                $children[] = $this->normalizeExpressionNode((array)$child);
            }
        }

        return [
            'type' => 'group',
            'operator' => $operator,
            'children' => $children,
        ];
    }

    /**
     * @param array<string,mixed> $expression
     */
    private function expressionHasPredicate(array $expression, string $subjectType, int $subjectId): bool
    {
        $type = strtolower((string)($expression['type'] ?? 'group'));
        if ($type === 'predicate') {
            return strtolower((string)($expression['subject_type'] ?? '')) === $subjectType
                && (int)($expression['subject_id'] ?? 0) === $subjectId;
        }
        if (in_array($type, ['branch', 'if', 'if_else'], true)) {
            foreach ((array)($expression['branches'] ?? []) as $branch) {
                if (!is_array($branch)) {
                    continue;
                }
                if (is_array($branch['when'] ?? null) && $this->expressionHasPredicate((array)$branch['when'], $subjectType, $subjectId)) {
                    return true;
                }
                if (is_array($branch['then'] ?? null) && $this->expressionHasPredicate((array)$branch['then'], $subjectType, $subjectId)) {
                    return true;
                }
            }
            return is_array($expression['default'] ?? null)
                && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId);
        }
        if ($type === 'case') {
            if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType
                && (int)($expression['subject_id'] ?? 0) === $subjectId) {
                return true;
            }
            foreach ((array)($expression['cases'] ?? []) as $case) {
                if (is_array($case) && is_array($case['then'] ?? null) && $this->expressionHasPredicate((array)$case['then'], $subjectType, $subjectId)) {
                    return true;
                }
            }
            return is_array($expression['default'] ?? null)
                && $this->expressionHasPredicate((array)$expression['default'], $subjectType, $subjectId);
        }

        foreach ((array)($expression['children'] ?? []) as $child) {
            if (is_array($child) && $this->expressionHasPredicate((array)$child, $subjectType, $subjectId)) {
                return true;
            }
        }
        return false;
    }

    /**
     * @param array<string,mixed> $expression
     * @return array<string,mixed>
     */
    private function removeExpressionPredicate(array $expression, string $subjectType, int $subjectId): array
    {
        $expression = $this->normalizeExpressionNode($expression);
        if (($expression['type'] ?? 'group') === 'predicate') {
            return $this->expressionHasPredicate($expression, $subjectType, $subjectId)
                ? $this->emptyV2Expression()
                : $expression;
        }
        if (in_array(strtolower((string)($expression['type'] ?? 'group')), ['branch', 'if', 'if_else'], true)) {
            foreach ((array)($expression['branches'] ?? []) as $index => $branch) {
                if (!is_array($branch)) {
                    continue;
                }
                if (is_array($branch['when'] ?? null)) {
                    $expression['branches'][$index]['when'] = $this->removeExpressionPredicate((array)$branch['when'], $subjectType, $subjectId);
                }
                if (is_array($branch['then'] ?? null)) {
                    $expression['branches'][$index]['then'] = $this->removeExpressionPredicate((array)$branch['then'], $subjectType, $subjectId);
                }
            }
            if (is_array($expression['default'] ?? null)) {
                $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId);
            }
            return $expression;
        }
        if (($expression['type'] ?? 'group') === 'case') {
            if (strtolower((string)($expression['subject_type'] ?? '')) === $subjectType
                && (int)($expression['subject_id'] ?? 0) === $subjectId) {
                return $this->emptyV2Expression();
            }
            foreach ((array)($expression['cases'] ?? []) as $index => $case) {
                if (is_array($case) && is_array($case['then'] ?? null)) {
                    $expression['cases'][$index]['then'] = $this->removeExpressionPredicate((array)$case['then'], $subjectType, $subjectId);
                }
            }
            if (is_array($expression['default'] ?? null)) {
                $expression['default'] = $this->removeExpressionPredicate((array)$expression['default'], $subjectType, $subjectId);
            }
            return $expression;
        }

        $children = [];
        foreach ((array)($expression['children'] ?? []) as $child) {
            if (!is_array($child)) {
                continue;
            }
            $normalizedChild = $this->normalizeExpressionNode((array)$child);
            if (($normalizedChild['type'] ?? '') === 'predicate' && $this->expressionHasPredicate($normalizedChild, $subjectType, $subjectId)) {
                continue;
            }
            if (($normalizedChild['type'] ?? '') === 'group') {
                $normalizedChild = $this->removeExpressionPredicate($normalizedChild, $subjectType, $subjectId);
            }
            $children[] = $normalizedChild;
        }

        $expression['children'] = $children;
        return $expression;
    }

    /**
     * @return array<string,mixed>
     */
    private function emptyV2Expression(): array
    {
        return [
            'type' => 'group',
            'operator' => 'ALL',
            'children' => [],
        ];
    }

    private function caseValueLabel(mixed $value): string
    {
        if ($value === true) {
            return 'true';
        }
        if ($value === false) {
            return 'false';
        }
        if ($value === null) {
            return 'unanswered';
        }
        return (string)$value;
    }

    /**
     * @param array<int,array<string,mixed>> $edges
     * @param array<string,mixed> $row
     */
    private function appendScopeEdges(array &$edges, string $targetId, array $row): void
    {
        $scopes = [
            'lane' => 'lane',
            'product' => 'vehicle_type',
            'machine_type_id' => 'machine_type',
        ];
        foreach ($scopes as $field => $type) {
            $scopeId = $this->nullableInt($row[$field] ?? null);
            if ($scopeId !== null) {
                $edges[] = $this->edge('scope:' . $type . ':' . $scopeId . ':' . $targetId, $type . ':' . $scopeId, $targetId, 'scope', 'scope');
            }
        }
    }

    /**
     * @param array<string,mixed> $layout
     * @param array<int,array<string,mixed>> $nodes
     * @return array<int,array<string,mixed>>
     */
    private function applyLayoutToNodes(array $nodes, array $layout): array
    {
        $positions = $this->extractNodePositions((array)($layout['nodes'] ?? []));
        foreach ($nodes as &$node) {
            $id = (string)($node['id'] ?? '');
            if (isset($positions[$id])) {
                $node['position'] = $positions[$id];
            }
        }
        unset($node);
        return array_values($nodes);
    }

    /**
     * @param array<int|string,mixed> $nodes
     * @return array<string,array{x:float|int,y:float|int}>
     */
    private function extractNodePositions(array $nodes): array
    {
        $positions = [];
        foreach ($nodes as $key => $node) {
            if (is_array($node) && isset($node['id'], $node['position']) && is_array($node['position'])) {
                $positions[(string)$node['id']] = [
                    'x' => (float)($node['position']['x'] ?? 0),
                    'y' => (float)($node['position']['y'] ?? 0),
                ];
                continue;
            }
            if (is_string($key) && is_array($node)) {
                $positions[$key] = [
                    'x' => (float)($node['x'] ?? $node['position']['x'] ?? 0),
                    'y' => (float)($node['y'] ?? $node['position']['y'] ?? 0),
                ];
            }
        }

        return $positions;
    }

    /**
     * @param array<string,mixed> $validation
     * @return array<int,array<string,mixed>>
     */
    private function buildValidationItems(array $validation): array
    {
        $items = [];
        foreach ((array)($validation['errors'] ?? []) as $message) {
            $items[] = [
                'severity' => 'error',
                'message' => (string)$message,
            ];
        }
        foreach ((array)($validation['warnings'] ?? []) as $message) {
            $items[] = [
                'severity' => 'warning',
                'message' => (string)$message,
            ];
        }
        return $items;
    }

    /**
     * @param array<string,mixed> $lookups
     * @return array<string,mixed>
     */
    private function buildSimulatorDefaults(int $departmentId, array $lookups, array $gatewayWorkspace = []): array
    {
        $lane = $this->lookupRows($lookups, 'lanes')[0] ?? null;
        $vehicleType = $this->lookupRows($lookups, 'vehicle_types')[0] ?? null;
        $hasVirtualHardware = (bool)($gatewayWorkspace['virtual']['has_virtual_hardware'] ?? false);
        return [
            'department' => $departmentId,
            'lane_id' => is_array($lane) ? (int)($lane['id'] ?? 0) : null,
            'vehicle_type_id' => is_array($vehicleType) ? (int)($vehicleType['id'] ?? 0) : null,
            'reg' => 'TEST123',
            'customer_number' => null,
            'hardware_mode' => $hasVirtualHardware ? 'studio' : 'real',
        ];
    }

    /**
     * @param array<string,mixed> $data
     * @return array<string,mixed>
     */
    private function node(string $id, string $type, string $label, string $kind, array $data, int $x, int $y): array
    {
        $data['kind'] = $kind;
        $data['label'] = $label;
        return [
            'id' => $id,
            'type' => $type,
            'position' => [
                'x' => $x,
                'y' => $y,
            ],
            'data' => $data,
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function edge(string $id, string $source, string $target, string $kind, string $label): array
    {
        return [
            'id' => $id,
            'source' => $source,
            'target' => $target,
            'type' => 'smoothstep',
            'label' => $label,
            'data' => [
                'kind' => $kind,
            ],
        ];
    }

    /**
     * @param array<string,mixed> $row
     * @param array<string,mixed> $lookups
     */
    private function entityLabel(string $entity, int $id, array $row, array $lookups): string
    {
        $field = match ($entity) {
            'question' => 'question',
            'condition', 'rule' => 'name',
            'task' => 'task',
            'action' => 'name',
            default => 'label',
        };
        $label = trim((string)($row[$field] ?? ''));
        if ($label !== '') {
            return $label;
        }
        return $this->labelFor($entity . 's', $id, $lookups);
    }

    /**
     * @param array<string,mixed> $action
     * @param array<string,mixed> $lookups
     */
    private function actionSubtitle(array $action, array $lookups): string
    {
        $parts = [
            selfserve_studio_actions::eventLabel((string)($action['event'] ?? '')),
            ucfirst((string)($action['wash_mode'] ?? selfserve_studio_actions::MODE_BOTH)),
            selfserve_studio_actions::operationLabel((string)($action['operation'] ?? ''), $action['relay_state'] ?? null),
        ];
        $scope = $this->scopeLabel($action, $lookups);
        if ($scope !== 'Shared scope') {
            $parts[] = $scope;
        }
        return implode(' / ', array_filter($parts, static fn(string $part): bool => $part !== ''));
    }

    /**
     * @param array<string,mixed> $lookups
     */
    private function labelFor(string $lookupType, mixed $id, array $lookups): string
    {
        $id = $this->nullableInt($id);
        if ($id === null) {
            return 'All';
        }
        $labels = is_array($lookups['labels'][$lookupType] ?? null) ? (array)$lookups['labels'][$lookupType] : [];
        return (string)($labels[(string)$id] ?? ucfirst(str_replace('_', ' ', rtrim($lookupType, 's'))) . ' ' . $id);
    }

    /**
     * @param array<string,mixed> $lookups
     * @return array<int,array<string,mixed>>
     */
    private function lookupRows(array $lookups, string $type): array
    {
        return isset($lookups[$type]) && is_array($lookups[$type]) ? array_values((array)$lookups[$type]) : [];
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @return array<int,array<string,mixed>>
     */
    private function sortedRows(array $rows, array $fields): array
    {
        usort($rows, static function (array $left, array $right) use ($fields): int {
            foreach ($fields as $field) {
                $leftValue = $left[$field] ?? null;
                $rightValue = $right[$field] ?? null;
                if (is_numeric($leftValue) && is_numeric($rightValue)) {
                    $comparison = (int)$leftValue <=> (int)$rightValue;
                } else {
                    $comparison = strcmp((string)$leftValue, (string)$rightValue);
                }
                if ($comparison !== 0) {
                    return $comparison;
                }
            }
            return 0;
        });
        return array_values($rows);
    }

    /**
     * @param array<string,mixed> $task
     * @return array<string,mixed>
     */
    private function normalizeTaskPayload(array $task): array
    {
        $task['services'] = $this->normalizeServiceList($task['services'] ?? []);
        $task['buttons'] = $this->normalizeArrayPayload($task['buttons'] ?? []);
        $task['attachments'] = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : [];
        return $task;
    }

    /**
     * @param array<string,mixed> $config
     * @return array<string,mixed>
     */
    private function withTaskAttachments(array $config): array
    {
        if (!is_array($config['tasks'] ?? null)) {
            return $config;
        }

        $config['tasks'] = (new selfserve_task_attachment_payloads())->attachToTasks(array_values((array)$config['tasks']));
        return $config;
    }

    /**
     * @param array<string,mixed> $workspace
     * @return array<string,array<int,string>>
     */
    private function relayServicesFromWorkspace(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'] ?? ''));
                $service = $this->normalizeServiceName($slot['slot'] ?? $slot['role'] ?? $slot['service'] ?? '');
                if ($relayId === '' || $service === '') {
                    continue;
                }
                $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>
     */
    private function bindingServices(array $binding, string $relayId, array $relayServices): array
    {
        $services = [];
        foreach (['role', 'service', 'slot'] as $field) {
            $service = $this->normalizeServiceName($binding[$field] ?? '');
            if ($service !== '') {
                $services[$service] = true;
            }
        }
        foreach ($this->normalizeServiceList($binding['services'] ?? []) as $service) {
            $services[$service] = true;
        }
        foreach ((array)($relayServices[$relayId] ?? []) as $service) {
            $normalized = $this->normalizeServiceName($service);
            if ($normalized !== '') {
                $services[$normalized] = true;
            }
        }

        return array_keys($services);
    }

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

    /**
     * @param array<string,mixed> $gateway
     */
    private function gatewayNodeId(array $gateway): string
    {
        $nodeId = trim((string)($gateway['node_id'] ?? ''));
        return $nodeId !== '' ? $nodeId : 'gateway:' . $this->gatewayIdentifier($gateway);
    }

    /**
     * @param array<string,mixed> $binding
     */
    private function bindingNodeId(string $gatewayId, string $relayId, int $bindingIndex, array $binding): string
    {
        $nodeId = trim((string)($binding['node_id'] ?? ''));
        return $nodeId !== '' ? $nodeId : 'binding:' . $gatewayId . ':' . $relayId . ':' . $bindingIndex;
    }

    /**
     * @param array<string,mixed> $workspace
     * @return array<int,array<string,mixed>>
     */
    private function gatewayBindingReferences(array $workspace): array
    {
        $relayServices = $this->relayServicesFromWorkspace($workspace);
        $references = [];
        foreach ((array)($workspace['gateways'] ?? []) as $gateway) {
            if (!is_array($gateway)) {
                continue;
            }
            $gatewayId = $this->gatewayIdentifier($gateway);
            if ($gatewayId === '') {
                continue;
            }
            foreach ((array)($gateway['bindings'] ?? []) as $bindingIndex => $binding) {
                if (!is_array($binding)) {
                    continue;
                }
                $relayId = trim((string)($binding['relay_id'] ?? ''));
                if ($relayId === '') {
                    continue;
                }
                $services = $this->bindingServices($binding, $relayId, $relayServices);
                if ($services === []) {
                    continue;
                }
                $references[] = [
                    'gateway_id' => $gatewayId,
                    'relay_id' => $relayId,
                    'binding_index' => (int)$bindingIndex,
                    'node_id' => $this->bindingNodeId($gatewayId, $relayId, (int)$bindingIndex, $binding),
                    'services' => $services,
                    'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false),
                ];
            }
        }

        return $references;
    }

    private function serviceForBindingNode(int $departmentId, string $nodeId): string
    {
        $workspace = $this->buildGatewayWorkspace($departmentId);
        foreach ($this->gatewayBindingReferences($workspace) as $binding) {
            if ((string)$binding['node_id'] === $nodeId) {
                return (string)($binding['services'][0] ?? '');
            }
        }
        return '';
    }

    private function updateTaskServiceConnection(int $departmentId, int $taskId, string $service, bool $disconnect): void
    {
        if ($taskId <= 0 || $service === '') {
            return;
        }

        $pdo = db::getPDO();
        $statement = $pdo->prepare(
            'SELECT services FROM department_selfserve_tasks WHERE id = :id AND department IN (0, :department) LIMIT 1'
        );
        $statement->execute([
            ':id' => $taskId,
            ':department' => $departmentId,
        ]);
        $row = $statement->fetch(\PDO::FETCH_ASSOC);
        if (!is_array($row)) {
            throw new \RuntimeException('Task is not available in the selected department.');
        }

        $services = $this->normalizeServiceList($row['services'] ?? []);
        $serviceSet = array_fill_keys($services, true);
        if ($disconnect) {
            unset($serviceSet[$service]);
        } else {
            $serviceSet[$service] = true;
        }

        $pdo->prepare(
            'UPDATE department_selfserve_tasks SET services = :services WHERE id = :id AND department IN (0, :department)'
        )->execute([
            ':services' => $this->jsonArray(array_keys($serviceSet)),
            ':id' => $taskId,
            ':department' => $departmentId,
        ]);
    }

    /**
     * @return array<int,mixed>
     */
    private function normalizeArrayPayload(mixed $value): array
    {
        if (is_string($value)) {
            $decoded = json_decode($value, true);
            $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value)), static fn(string $item): bool => $item !== '');
        }
        return is_array($value) ? array_values($value) : [];
    }

    /**
     * @return array<int,string>
     */
    private function normalizeServiceList(mixed $value): array
    {
        $services = [];
        foreach ($this->normalizeArrayPayload($value) as $entry) {
            $service = $this->normalizeServiceName($entry);
            if ($service !== '') {
                $services[$service] = true;
            }
        }
        return array_keys($services);
    }

    private function normalizeServiceName(mixed $value): string
    {
        return strtoupper(trim((string)$value));
    }

    /**
     * @param array<int,bool> $answers
     * @return array<int,array{question_id:int,value:bool}>
     */
    private function pathAnswerOverrides(array $answers): array
    {
        ksort($answers, SORT_NUMERIC);
        $overrides = [];
        foreach ($answers as $questionId => $answer) {
            if ($answer !== true && $answer !== false) {
                continue;
            }
            $overrides[] = [
                'question_id' => (int)$questionId,
                'value' => $answer,
            ];
        }
        return $overrides;
    }

    /**
     * @param array<string,mixed> $simulation
     * @param array<int,bool> $answers
     * @return array<string,mixed>|null
     */
    private function nextPathQuestion(array $simulation, array $answers): ?array
    {
        $debugQuestions = is_array($simulation['debug']['questions'] ?? null) ? (array)$simulation['debug']['questions'] : [];
        foreach ($debugQuestions as $question) {
            if (!is_array($question)) {
                continue;
            }
            $questionId = (int)($question['id'] ?? 0);
            if ($questionId <= 0 || array_key_exists($questionId, $answers)) {
                continue;
            }
            $visible = !array_key_exists('visible', $question) || (bool)$question['visible'] === true;
            $answer = $question['answer'] ?? null;
            if ($visible && $answer !== true && $answer !== false) {
                return $question;
            }
        }

        $visibleQuestions = is_array($simulation['questions'] ?? null) ? (array)$simulation['questions'] : [];
        foreach ($visibleQuestions as $question) {
            if (!is_array($question)) {
                continue;
            }
            $questionId = (int)($question['id'] ?? 0);
            if ($questionId <= 0 || array_key_exists($questionId, $answers)) {
                continue;
            }
            $answer = $question['answer'] ?? null;
            if ($answer !== true && $answer !== false) {
                return [
                    'id' => $questionId,
                    'label' => (string)($question['question'] ?? ('Question ' . $questionId)),
                    'node_id' => 'question:' . $questionId,
                    'answer' => $answer,
                    'visible' => true,
                ];
            }
        }

        return null;
    }

    private function pathLimit(mixed $value, int $default, int $max, int $min = 1): int
    {
        if ($value === null || $value === '') {
            return max($min, min($max, $default));
        }

        $parsed = (int)$value;
        if ($parsed < $min) {
            return max($min, min($max, $default));
        }

        return min($max, $parsed);
    }

    /**
     * @param array<string,mixed> $scope
     * @param array<string,array<string,mixed>> $groups
     * @param array<int,array<string,mixed>> $paths
     * @param array<int,bool> $questionIds
     * @return array<string,mixed>
     */
    private function pathOutcomesProjectionPayload(
        array $scope,
        array $groups,
        array $paths,
        bool $truncated,
        ?int $maxStates,
        int $stateCount,
        int $terminalPathCount,
        array $questionIds,
        int $pendingStateCount,
        array $confirmationRows = []
    ): array {
        $warnings = [];
        if ($truncated && $maxStates !== null) {
            $warnings[] = 'Path projection was truncated at ' . $maxStates . ' explored state(s).';
        }

        $knownStateCount = max(1, $stateCount + $pendingStateCount);
        $complete = !$truncated && $pendingStateCount === 0;
        $percent = $complete ? 100 : min(99, max(1, (int)floor(($stateCount / $knownStateCount) * 100)));

        return $this->pathOutcomesPayload(
            $scope,
            $this->finalizePathOutcomeGroups($groups),
            $paths,
            $warnings,
            $truncated,
            $maxStates,
            $stateCount,
            $terminalPathCount,
            $questionIds,
            [
                'complete' => $complete,
                'percent' => $percent,
                'state_count' => $stateCount,
                'pending_state_count' => $pendingStateCount,
                'terminal_path_count' => $terminalPathCount,
            ],
            $confirmationRows
        );
    }

    /**
     * @param array<string,mixed> $scope
     * @param array<int,array<string,mixed>> $outcomes
     * @param array<int,array<string,mixed>> $paths
     * @param array<int,string> $warnings
     * @param array<int,bool>|array<int,int> $questionIds
     * @param array<string,mixed> $progress
     * @return array<string,mixed>
     */
    private function pathOutcomesPayload(
        array $scope,
        array $outcomes,
        array $paths,
        array $warnings,
        bool $truncated,
        ?int $maxStates,
        int $stateCount,
        int $terminalPathCount,
        array $questionIds,
        array $progress = [],
        ?array $confirmationRows = null
    ): array {
        $confirmationRows = $confirmationRows ?? [];

        usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
            ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
        foreach ($outcomes as $index => &$outcome) {
            $outcome['id'] = 'outcome-' . ($index + 1);
        }
        unset($outcome);

        foreach ($paths as $index => &$path) {
            $path['id'] = 'path-' . ($index + 1);
        }
        unset($path);

        $confirmations = $this->applyPathConfirmations($paths, $confirmationRows);

        $questionIdValues = [];
        foreach ($questionIds as $key => $value) {
            $questionIdValues[] = $value === true ? (int)$key : (int)$value;
        }
        $questionIdValues = array_values(array_unique(array_filter($questionIdValues, static fn(int $id): bool => $id > 0)));
        sort($questionIdValues);

        $progress = array_merge([
            'complete' => !$truncated,
            'percent' => $truncated ? 99 : 100,
            'state_count' => $stateCount,
            'pending_state_count' => 0,
            'terminal_path_count' => $terminalPathCount,
        ], $progress);

        return [
            'scope' => $scope,
            'summary' => [
                'state_count' => $stateCount,
                'terminal_path_count' => $terminalPathCount,
                'outcome_count' => count($outcomes),
                'question_count' => count($questionIdValues),
                'question_ids' => $questionIdValues,
                'max_states' => $maxStates,
                'path_sample_count' => count($paths),
                'confirmations' => $confirmations['summary'],
            ],
            'outcomes' => array_values($outcomes),
            'paths' => array_values($paths),
            'confirmations' => $confirmations,
            'warnings' => array_values(array_unique($warnings)),
            'truncated' => $truncated,
            'progress' => $progress,
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $paths
     * @param array<int,array<string,mixed>> $confirmationRows
     * @return array{summary:array<string,int>,removed:array<int,array<string,mixed>>}
     */
    private function applyPathConfirmations(array &$paths, array $confirmationRows): array
    {
        $rowsBySignature = [];
        foreach ($confirmationRows as $row) {
            if (!is_array($row)) {
                continue;
            }
            $signature = trim((string)($row['path_signature'] ?? ''));
            if ($signature !== '') {
                $rowsBySignature[$signature] = $row;
            }
        }

        $matched = [];
        $summary = [
            'confirmed' => 0,
            'unconfirmed' => 0,
            'stale' => 0,
            'removed' => 0,
            'total' => count($paths),
        ];

        foreach ($paths as &$path) {
            if (!is_array($path)) {
                continue;
            }
            $pathSignature = $this->pathSignature(
                is_array($path['scope'] ?? null) ? (array)$path['scope'] : [],
                is_array($path['answers'] ?? null) ? (array)$path['answers'] : []
            );
            $resultSignature = $this->pathResultSignature($path);
            $path['path_signature'] = $pathSignature;
            $path['result_signature'] = $resultSignature;
            $path['confirmation_status'] = 'unconfirmed';
            $path['confirmed_at'] = null;
            $path['confirmed_by'] = null;
            $path['stale_reason'] = null;

            $row = $rowsBySignature[$pathSignature] ?? null;
            if (is_array($row)) {
                $matched[$pathSignature] = true;
                $path['confirmed_at'] = $row['confirmed_at'] ?? null;
                $path['confirmed_by'] = $row['confirmed_by'] ?? null;
                if ((string)($row['result_signature'] ?? '') === $resultSignature) {
                    $path['confirmation_status'] = 'confirmed';
                } else {
                    $path['confirmation_status'] = 'stale';
                    $path['stale_reason'] = 'Result changed since confirmation.';
                }
            }

            $summary[(string)$path['confirmation_status']]++;
        }
        unset($path);

        $removed = [];
        foreach ($rowsBySignature as $signature => $row) {
            if (isset($matched[$signature])) {
                continue;
            }
            $removed[] = [
                'id' => $row['id'] ?? null,
                'path_signature' => $signature,
                'result_signature' => (string)($row['result_signature'] ?? ''),
                'confirmation_status' => 'stale',
                'stale_reason' => 'Path no longer appears in the projected cases.',
                'answers' => is_array($row['answers'] ?? null) ? $row['answers'] : [],
                'result' => is_array($row['result'] ?? null) ? $row['result'] : [],
                'scope' => is_array($row['scope'] ?? null) ? $row['scope'] : [],
                'confirmed_at' => $row['confirmed_at'] ?? null,
                'confirmed_by' => $row['confirmed_by'] ?? null,
            ];
        }
        $summary['removed'] = count($removed);

        return [
            'summary' => $summary,
            'removed' => $removed,
        ];
    }

    /**
     * @param array<string,array<string,mixed>> $groups
     * @param array<string,mixed> $simulation
     * @param array<int,array<string,mixed>> $chain
     * @param array<string,mixed> $scope
     */
    private function addPathOutcomeGroup(array &$groups, array $simulation, array $chain, array $scope, int $sampleLimit): void
    {
        $tasks = $this->pathActiveTasks($simulation);
        $services = $this->pathServices($simulation, $tasks);
        $signals = $this->pathSignals($simulation);
        $allowed = (bool)($simulation['allowed'] ?? false);
        $key = $this->stableJson([
            'scope' => $this->pathScopeKey($scope),
            'allowed' => $allowed,
            'services' => $services,
            'tasks' => array_map(static fn(array $task): array => [
                'id' => (int)($task['id'] ?? 0),
                'services' => (array)($task['services'] ?? []),
            ], $tasks),
            'signals' => $signals,
        ]);

        if (!isset($groups[$key])) {
            $groups[$key] = [
                'path_count' => 0,
                'allowed' => $allowed,
                'services' => $services,
                'tasks' => $tasks,
                'signals' => $signals,
                'sample_chains' => [],
                'scopes' => [],
                'node_ids' => [],
                'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals),
            ];
        }

        $groups[$key]['path_count'] = (int)$groups[$key]['path_count'] + 1;
        $scopeKey = $this->stableJson($this->pathScopeKey($scope));
        $groups[$key]['scopes'][$scopeKey] = $scope;
        if (count((array)$groups[$key]['sample_chains']) < $sampleLimit) {
            $groups[$key]['sample_chains'][] = [
                'scope' => $scope,
                'answers' => array_values($chain),
            ];
        }

        foreach ($this->pathNodeIds($chain, $tasks, $signals) as $nodeId) {
            $groups[$key]['node_ids'][$nodeId] = true;
        }
    }

    /**
     * @param array<string,mixed> $simulation
     * @param array<int,array<string,mixed>> $chain
     * @param array<string,mixed> $scope
     * @return array<string,mixed>
     */
    private function pathResultFromSimulation(array $simulation, array $chain, array $scope): array
    {
        $tasks = $this->pathActiveTasks($simulation);
        $services = $this->pathServices($simulation, $tasks);
        $signals = $this->pathSignals($simulation);
        $allowed = (bool)($simulation['allowed'] ?? false);

        return [
            'id' => '',
            'result' => $allowed ? 'Allowed' : 'Blocked',
            'summary' => $this->pathOutcomeSummary($allowed, $services, $tasks, $signals),
            'allowed' => $allowed,
            'services' => $services,
            'tasks' => $tasks,
            'signals' => $signals,
            'task_count' => count($tasks),
            'signal_count' => count($signals),
            'answers' => array_values($chain),
            'scope' => $scope,
            'node_ids' => $this->pathNodeIds($chain, $tasks, $signals),
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $chain
     * @param array<int,array<string,mixed>> $tasks
     * @param array<int,array<string,mixed>> $signals
     * @return array<int,string>
     */
    private function pathNodeIds(array $chain, array $tasks, array $signals): array
    {
        $nodeIds = [];
        foreach ($chain as $answer) {
            if (is_array($answer) && trim((string)($answer['node_id'] ?? '')) !== '') {
                $nodeIds[(string)$answer['node_id']] = true;
            }
        }
        foreach ($tasks as $task) {
            if (trim((string)($task['node_id'] ?? '')) !== '') {
                $nodeIds[(string)$task['node_id']] = true;
            }
        }
        foreach ($signals as $signal) {
            foreach (['target_binding', 'target_relay_node_id', 'target_gateway_node_id'] as $field) {
                if (trim((string)($signal[$field] ?? '')) !== '') {
                    $nodeIds[(string)$signal[$field]] = true;
                }
            }
        }

        $nodeIds = array_keys($nodeIds);
        sort($nodeIds);
        return $nodeIds;
    }

    /**
     * @param array<string,array<string,mixed>> $groups
     * @return array<int,array<string,mixed>>
     */
    private function finalizePathOutcomeGroups(array $groups): array
    {
        $outcomes = [];
        foreach ($groups as $group) {
            $scopes = array_values((array)($group['scopes'] ?? []));
            $nodeIds = array_values(array_keys((array)($group['node_ids'] ?? [])));
            sort($nodeIds);
            $outcomes[] = [
                'id' => '',
                'summary' => (string)($group['summary'] ?? ''),
                'path_count' => (int)($group['path_count'] ?? 0),
                'allowed' => (bool)($group['allowed'] ?? false),
                'services' => array_values((array)($group['services'] ?? [])),
                'tasks' => array_values((array)($group['tasks'] ?? [])),
                'signals' => array_values((array)($group['signals'] ?? [])),
                'sample_chains' => array_values((array)($group['sample_chains'] ?? [])),
                'scopes' => $scopes,
                'node_ids' => $nodeIds,
            ];
        }

        usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
            ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
        foreach ($outcomes as $index => &$outcome) {
            $outcome['id'] = 'outcome-' . ($index + 1);
        }
        unset($outcome);
        return $outcomes;
    }

    /**
     * @param array<string,mixed> $simulation
     * @return array<int,array<string,mixed>>
     */
    private function pathActiveTasks(array $simulation): array
    {
        $tasks = [];
        $debugTasks = is_array($simulation['debug']['tasks'] ?? null) ? (array)$simulation['debug']['tasks'] : [];
        foreach ($debugTasks as $task) {
            if (!is_array($task) || (bool)($task['active'] ?? false) !== true) {
                continue;
            }
            $tasks[] = [
                'id' => (int)($task['id'] ?? 0),
                'node_id' => (string)($task['node_id'] ?? ('task:' . (int)($task['id'] ?? 0))),
                'label' => (string)($task['label'] ?? $task['task'] ?? ('Task ' . (int)($task['id'] ?? 0))),
                'services' => $this->normalizeServiceList($task['services'] ?? []),
                'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []),
                'order_priority' => (int)($task['order_priority'] ?? 0),
            ];
        }
        if ($tasks !== []) {
            return $tasks;
        }

        foreach ((array)($simulation['tasks'] ?? []) as $task) {
            if (!is_array($task)) {
                continue;
            }
            $taskId = (int)($task['id'] ?? $task['task_id'] ?? 0);
            $tasks[] = [
                'id' => $taskId,
                'node_id' => 'task:' . $taskId,
                'label' => (string)($task['task'] ?? $task['label'] ?? ('Task ' . $taskId)),
                'services' => $this->normalizeServiceList($task['services'] ?? []),
                'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []),
                'order_priority' => (int)($task['order_priority'] ?? 0),
            ];
        }
        usort($tasks, 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)));
        return $tasks;
    }

    /**
     * @param array<string,mixed> $simulation
     * @param array<int,array<string,mixed>> $tasks
     * @return array<int,string>
     */
    private function pathServices(array $simulation, array $tasks): array
    {
        $services = [];
        foreach ((array)($simulation['allowed_services'] ?? []) as $service) {
            $normalized = $this->normalizeServiceName($service);
            if ($normalized !== '') {
                $services[$normalized] = true;
            }
        }
        foreach ($tasks as $task) {
            foreach ($this->normalizeServiceList($task['services'] ?? []) as $service) {
                $services[$service] = true;
            }
        }
        $values = array_keys($services);
        sort($values);
        return $values;
    }

    /**
     * @param array<string,mixed> $simulation
     * @return array<int,array<string,mixed>>
     */
    private function pathSignals(array $simulation): array
    {
        $timeline = is_array($simulation['debug']['signal_timeline'] ?? null)
            ? (array)$simulation['debug']['signal_timeline']
            : (is_array($simulation['debug']['hardware']['signal_timeline'] ?? null) ? (array)$simulation['debug']['hardware']['signal_timeline'] : []);
        $signals = [];
        foreach ($timeline as $index => $signal) {
            if (!is_array($signal)) {
                continue;
            }
            $signals[] = [
                'sequence' => (int)($signal['sequence'] ?? ($index + 1)),
                'runtime_stage' => (string)($signal['runtime_stage'] ?? ''),
                'signal_type' => (string)($signal['signal_type'] ?? ''),
                'relay_role' => (string)($signal['relay_role'] ?? ''),
                'relay_id' => $signal['relay_id'] ?? null,
                'target_gateway_label' => $signal['target_gateway_label'] ?? null,
                'target_binding' => $signal['target_binding'] ?? null,
                'target_gateway_node_id' => $signal['target_gateway_node_id'] ?? null,
                'target_relay_node_id' => $signal['target_relay_node_id'] ?? null,
                'source' => (string)($signal['source'] ?? ''),
                'virtual' => (bool)($signal['virtual'] ?? false),
                'predicted_status' => (string)($signal['predicted_status'] ?? ''),
                'payload' => $this->sortStableValue(is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : []),
                'skip_block_reason' => $signal['skip_block_reason'] ?? null,
            ];
        }
        return $signals;
    }

    /**
     * @param array<string,mixed> $scope
     * @return array<string,mixed>
     */
    private function pathScopeKey(array $scope): array
    {
        return [
            'department_id' => $scope['department_id'] ?? null,
            'lane_id' => $scope['lane_id'] ?? null,
            'vehicle_type_id' => $scope['vehicle_type_id'] ?? null,
            'machine_type_id' => $scope['machine_type_id'] ?? null,
            'config_source' => $scope['config_source'] ?? null,
            'config_version_id' => $scope['config_version_id'] ?? null,
            'hardware_mode' => $scope['hardware_mode'] ?? null,
        ];
    }

    /**
     * @param array<string,mixed> $path
     */
    private function pathResultSignature(array $path): string
    {
        return hash('sha256', $this->stableJson([
            'allowed' => (bool)($path['allowed'] ?? false),
            'services' => $this->normalizeServiceList($path['services'] ?? []),
            'tasks' => array_values(array_map(function (array $task): array {
                return [
                    'id' => (int)($task['id'] ?? 0),
                    'label' => (string)($task['label'] ?? $task['task'] ?? ''),
                    'services' => $this->normalizeServiceList($task['services'] ?? []),
                    'buttons' => $this->normalizeArrayPayload($task['buttons'] ?? []),
                    'order_priority' => (int)($task['order_priority'] ?? 0),
                ];
            }, array_values((array)($path['tasks'] ?? [])))),
            'buttons' => array_values(array_reduce(
                array_values((array)($path['tasks'] ?? [])),
                function (array $carry, mixed $task): array {
                    if (!is_array($task)) {
                        return $carry;
                    }
                    foreach ($this->normalizeArrayPayload($task['buttons'] ?? []) as $button) {
                        $key = (is_int($button) ? 'int:' : 'string:') . (string)$button;
                        $carry[$key] = $button;
                    }
                    return $carry;
                },
                []
            )),
            'signals' => array_values(array_map(static fn(array $signal): array => [
                'runtime_stage' => (string)($signal['runtime_stage'] ?? ''),
                'signal_type' => (string)($signal['signal_type'] ?? ''),
                'relay_role' => (string)($signal['relay_role'] ?? ''),
                'relay_id' => $signal['relay_id'] ?? null,
                'target_binding' => $signal['target_binding'] ?? null,
                'source' => (string)($signal['source'] ?? ''),
                'predicted_status' => (string)($signal['predicted_status'] ?? ''),
                'payload' => is_array($signal['payload'] ?? null) ? (array)$signal['payload'] : [],
            ], array_values((array)($path['signals'] ?? [])))),
        ]));
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function loadPathConfirmationRows(int $departmentId, ?int $configVersionId, int $laneId, ?int $vehicleTypeId, string $configSource): array
    {
        if (!$this->tableExists('department_selfserve_path_confirmations')) {
            return [];
        }

        $where = [
            'department_id = :department_id',
            'lane_id = :lane_id',
            'config_source = :config_source',
            'deleted_at IS NULL',
        ];
        $params = [
            ':department_id' => $departmentId,
            ':lane_id' => $laneId,
            ':config_source' => $configSource,
        ];
        if ($configVersionId === null) {
            $where[] = 'config_version_id IS NULL';
        } else {
            $where[] = 'config_version_id = :config_version_id';
            $params[':config_version_id'] = $configVersionId;
        }
        if ($vehicleTypeId !== null) {
            $where[] = 'vehicle_type_id = :vehicle_type_id';
            $params[':vehicle_type_id'] = $vehicleTypeId;
        }

        $statement = db::getPDO()->prepare(
            'SELECT *
             FROM department_selfserve_path_confirmations
             WHERE ' . implode(' AND ', $where) . '
             ORDER BY confirmed_at DESC, id DESC'
        );
        $statement->execute($params);
        $rows = $statement->fetchAll(\PDO::FETCH_ASSOC) ?: [];

        return array_values(array_map(function (array $row): array {
            $row['answers'] = $this->decodeJsonArray($row['answers_json'] ?? null);
            $row['result'] = $this->decodeJsonArray($row['result_json'] ?? null);
            $row['scope'] = $this->decodeJsonArray($row['scope_json'] ?? null);
            return $row;
        }, $rows));
    }

    /**
     * @return array<string,mixed>|array<int,mixed>
     */
    private function decodeJsonArray(mixed $value): array
    {
        if (is_array($value)) {
            return $value;
        }
        $decoded = json_decode((string)($value ?? '[]'), true);
        return is_array($decoded) ? $decoded : [];
    }

    /**
     * @param array<int,string> $services
     * @param array<int,array<string,mixed>> $tasks
     * @param array<int,array<string,mixed>> $signals
     */
    private function pathOutcomeSummary(bool $allowed, array $services, array $tasks, array $signals): string
    {
        $serviceLabel = $services === [] ? 'No services' : implode(', ', $services);
        $taskText = count($tasks) === 1 ? '1 task' : count($tasks) . ' tasks';
        $signalText = count($signals) === 1 ? '1 signal' : count($signals) . ' signals';
        return ($allowed ? 'Allowed' : 'Blocked') . ' / ' . $serviceLabel . ' / ' . $taskText . ' / ' . $signalText;
    }

    private function stableJson(mixed $value): string
    {
        $json = json_encode($this->sortStableValue($value), JSON_UNESCAPED_UNICODE);
        if ($json === false) {
            return '';
        }
        return $json;
    }

    private function sortStableValue(mixed $value): mixed
    {
        if (!is_array($value)) {
            return $value;
        }

        $isList = array_keys($value) === range(0, count($value) - 1);
        if (!$isList) {
            ksort($value);
        }
        foreach ($value as $key => $item) {
            $value[$key] = $this->sortStableValue($item);
        }
        return $value;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @return array<int,array<string,mixed>>
     */
    private function vehicleTypeRowsFromProducts(array $rows): array
    {
        $vehicleTypes = [];
        foreach ($this->labelRows($rows, 'name') as $row) {
            $productId = (int)($row['id'] ?? 0);
            if ($productId <= 0 || (int)($row['is_wash'] ?? 0) !== 1 || (int)($row['subscription_allowed'] ?? 0) !== 1) {
                continue;
            }

            $row['id'] = $productId;
            $row['product'] = $productId;
            $row['product_id'] = $productId;
            $row['source'] = 'products';
            $vehicleTypes[] = $row;
        }

        return $vehicleTypes;
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @return array<int,array<string,mixed>>
     */
    private function labelRows(array $rows, string $labelField): array
    {
        return array_map(static function (array $row) use ($labelField): array {
            $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? '');
            return $row;
        }, $rows);
    }

    /**
     * @param array<int,array<string,mixed>> $rows
     * @return array<int,array<string,mixed>>
     */
    private function configLabelRows(array $rows, string $labelField): array
    {
        return array_map(static function (array $row) use ($labelField): array {
            $row['label'] = trim((string)($row[$labelField] ?? '')) ?: (string)($row['id'] ?? '');
            return [
                'id' => (int)($row['id'] ?? 0),
                'label' => $row['label'],
                'raw' => $row,
            ];
        }, $rows);
    }

    /**
     * @param array<int,array<string,mixed>> $gateways
     * @return array<int,array<string,mixed>>
     */
    private function gatewayLabelRows(array $gateways): array
    {
        $rows = [];
        foreach ($gateways as $gateway) {
            if (is_array($gateway)) {
                $gatewayId = $this->gatewayIdentifier($gateway);
                if ($gatewayId === '') {
                    continue;
                }
                $rows[] = [
                    'id' => $gatewayId,
                    'label' => (string)($gateway['label'] ?? ('Gateway ' . $gatewayId)),
                    'status' => (string)($gateway['status'] ?? 'UNKNOWN'),
                    'virtual' => (bool)($gateway['virtual'] ?? false),
                    'raw' => $gateway,
                ];
            }
        }
        return $rows;
    }

    /**
     * @param array<int|string,array<string,mixed>> $relays
     * @return array<int,array<string,mixed>>
     */
    private function relayLabelRows(array $relays): array
    {
        $rows = [];
        foreach ($relays as $relay) {
            if (!is_array($relay)) {
                continue;
            }
            $relayId = trim((string)($relay['relay_id'] ?? $relay['id'] ?? ''));
            if ($relayId === '') {
                continue;
            }
            $rows[] = [
                'id' => $relayId,
                'label' => (string)($relay['name'] ?? ('Relay ' . $relayId)),
                'raw' => $relay,
            ];
        }
        return $rows;
    }

    /**
     * @param array<int,array<string,mixed>> $gateways
     * @return array<int,array<string,mixed>>
     */
    private function bindingLabelRows(array $gateways): array
    {
        $rows = [];
        foreach ($gateways as $gateway) {
            if (!is_array($gateway)) {
                continue;
            }
            $gatewayId = $this->gatewayIdentifier($gateway);
            foreach ((array)($gateway['bindings'] ?? []) as $index => $binding) {
                if (!is_array($binding)) {
                    continue;
                }
                $relayId = trim((string)($binding['relay_id'] ?? ''));
                if ($gatewayId === '' || $relayId === '') {
                    continue;
                }
                $services = $this->bindingServices($binding, $relayId, []);
                $rows[] = [
                    'id' => $gatewayId . ':' . $relayId . ':' . $index,
                    'label' => (string)($binding['label'] ?? ('Gateway ' . $gatewayId . ' relay ' . $relayId)),
                    'gateway_id' => $gatewayId,
                    'relay_id' => $relayId,
                    'role' => (string)($binding['role'] ?? ''),
                    'services' => $services,
                    'virtual' => (bool)($gateway['virtual'] ?? $binding['virtual'] ?? false),
                ];
            }
        }
        return $rows;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function fetchRows(string $table, array $columns, array $where): array
    {
        if (!$this->tableExists($table)) {
            return [];
        }
        $availableColumns = $this->tableColumns($table);
        $columns = array_values(array_filter($columns, static fn(string $column): bool => in_array($column, $availableColumns, true)));
        if ($columns === []) {
            return [];
        }

        $conditions = [];
        $params = [];
        foreach ($where as $field => $value) {
            if (!in_array($field, $availableColumns, true)) {
                continue;
            }
            $conditions[] = '`' . $field . '` = :' . $field;
            $params[':' . $field] = $value;
        }
        if (in_array('deleted_at', $availableColumns, true)) {
            $conditions[] = '`deleted_at` IS NULL';
        }
        $sql = 'SELECT `' . implode('`, `', $columns) . '` FROM `' . $table . '`';
        if ($conditions !== []) {
            $sql .= ' WHERE ' . implode(' AND ', $conditions);
        }
        if (in_array('order_priority', $availableColumns, true)) {
            $sql .= ' ORDER BY `order_priority` ASC, `id` ASC';
        } elseif (in_array('id', $availableColumns, true)) {
            $sql .= ' ORDER BY `id` ASC';
        }

        $statement = db::getPDO()->prepare($sql);
        $statement->execute($params);
        return $statement->fetchAll(\PDO::FETCH_ASSOC) ?: [];
    }

    private function tableExists(string $table): bool
    {
        $statement = db::getPDO()->prepare(
            'SELECT COUNT(*) AS c FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table'
        );
        $statement->execute([':table' => $table]);
        return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0;
    }

    /**
     * @return array<int,string>
     */
    private function tableColumns(string $table): array
    {
        if (isset($this->columnCache[$table])) {
            return $this->columnCache[$table];
        }
        $statement = db::getPDO()->prepare(
            'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table'
        );
        $statement->execute([':table' => $table]);
        $this->columnCache[$table] = array_map(
            static fn(array $row): string => (string)$row['COLUMN_NAME'],
            $statement->fetchAll(\PDO::FETCH_ASSOC) ?: []
        );
        return $this->columnCache[$table];
    }

    private function normalizeEntity(string $entity): string
    {
        $entity = strtolower(trim($entity));
        return match ($entity) {
            'questions' => 'question',
            'conditions' => 'condition',
            'rules' => 'rule',
            'tasks' => 'task',
            'actions' => 'action',
            'lanes' => 'lane',
            'paths' => 'path',
            default => $entity,
        };
    }

    private function normalizeGateType(string $gateType): string
    {
        $gateType = strtoupper(trim($gateType));
        return in_array($gateType, [
            selfserve_task_gate_type::ALWAYS->value,
            selfserve_task_gate_type::CONDITION->value,
            selfserve_task_gate_type::QUESTION->value,
        ], true) ? $gateType : selfserve_task_gate_type::ALWAYS->value;
    }

    private function conditionBelongsToDepartment(int $conditionId, int $departmentId): bool
    {
        if ($conditionId <= 0) {
            return false;
        }

        $statement = db::getPDO()->prepare(
            "SELECT COUNT(*) AS c
             FROM department_selfserve_conditions
             WHERE id = :id
               AND department IN (0, :department)
               AND deleted_at IS NULL"
        );
        $statement->execute([
            ':id' => $conditionId,
            ':department' => $departmentId,
        ]);

        return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0;
    }

    private function laneBelongsToDepartment(int $laneId, int $departmentId): bool
    {
        if ($laneId <= 0) {
            return false;
        }

        $statement = db::getPDO()->prepare(
            "SELECT COUNT(*) AS c
             FROM department_lanes
             WHERE id = :id
               AND department = :department
               AND deleted_at IS NULL"
        );
        $statement->execute([
            ':id' => $laneId,
            ':department' => $departmentId,
        ]);

        return (int)($statement->fetch(\PDO::FETCH_ASSOC)['c'] ?? 0) > 0;
    }

    /**
     * @return array{0:string,1:string}
     */
    private function parseNodeId(string $nodeId): array
    {
        $parts = explode(':', $nodeId, 2);
        return [
            strtolower((string)($parts[0] ?? '')),
            (string)($parts[1] ?? ''),
        ];
    }

    private function jsonArray(mixed $value): string
    {
        if (is_string($value)) {
            $decoded = json_decode($value, true);
            $value = is_array($decoded) ? $decoded : array_filter(array_map('trim', explode(',', $value)));
        }
        if (!is_array($value)) {
            $value = [];
        }
        $json = json_encode(array_values($value), JSON_UNESCAPED_UNICODE);
        if ($json === false) {
            throw new \RuntimeException('Failed to encode JSON array: ' . json_last_error_msg());
        }
        return $json;
    }

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