<?php

app_require('modules/selfserve/classes/selfserve_studio_graph.php');
app_require('modules/selfserve/classes/selfserve_virtual_hardware.php');

use modules\selfserve\classes\selfserve_studio_graph;
use modules\selfserve\classes\selfserve_config_versioning;
use modules\selfserve\classes\selfserve_virtual_hardware;
use modules\selfserve\classes\selfserve_wash_flow;

function selfserve_studio_graph_without_constructor(): selfserve_studio_graph
{
    $reflection = new ReflectionClass(selfserve_studio_graph::class);
    return $reflection->newInstanceWithoutConstructor();
}

function selfserve_wash_flow_without_constructor(): selfserve_wash_flow
{
    $reflection = new ReflectionClass(selfserve_wash_flow::class);
    return $reflection->newInstanceWithoutConstructor();
}

function selfserve_virtual_hardware_without_constructor(): selfserve_virtual_hardware
{
    $reflection = new ReflectionClass(selfserve_virtual_hardware::class);
    return $reflection->newInstanceWithoutConstructor();
}

function selfserve_question_tree_simulator(int $questionCount): callable
{
    return function (array $overrides) use ($questionCount): array {
        $answers = [];
        foreach ($overrides as $entry) {
            $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
        }

        $questions = [];
        foreach (range(1, $questionCount) as $questionId) {
            $questions[] = [
                'id' => $questionId,
                'node_id' => 'question:' . $questionId,
                'label' => 'Question ' . $questionId,
                'visible' => true,
                'answer' => $answers[$questionId] ?? null,
            ];
        }

        $complete = count($answers) === $questionCount;
        $allowed = $complete && !in_array(false, $answers, true);

        return [
            'allowed' => $allowed,
            'questions' => [],
            'tasks' => $allowed ? [
                ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']],
            ] : [],
            'allowed_services' => $allowed ? ['MACHINE'] : [],
            'debug' => [
                'questions' => $questions,
                'tasks' => [
                    [
                        'id' => 41,
                        'node_id' => 'task:41',
                        'label' => 'Start machine',
                        'active' => $allowed,
                        'services' => ['MACHINE'],
                        'buttons' => ['start'],
                        'order_priority' => 1,
                    ],
                ],
                'signal_timeline' => [],
            ],
        ];
    };
}

it('serializes questions, conditions, tasks, scopes, and gateways into one graph', function (): void {
    $service = selfserve_studio_graph_without_constructor();

    $graph = $service->buildGraphFromConfig([
        'questions' => [
            ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => 10, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1],
        ],
        'conditions' => [
            ['id' => 10, 'name' => 'Trailer present', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2],
        ],
        'rules' => [
            ['id' => 20, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1, 'name' => 'Mirror answer'],
        ],
        'tasks' => [
            ['id' => 30, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 1, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1, 'services' => ['MACHINE']],
        ],
    ], [
        'lookups' => [
            'departments' => [['id' => 2, 'label' => 'Roskilde']],
            'lanes' => [['id' => 7, 'label' => 'Lane 7']],
            'products' => [['id' => 3, 'label' => 'Forvogn']],
            'machine_types' => [],
            'vehicle_types' => [['id' => 3, 'product' => 3, 'label' => 'Forvogn', 'source' => 'products']],
            'labels' => [
                'departments' => ['2' => 'Roskilde'],
                'lanes' => ['7' => 'Lane 7'],
                'products' => ['3' => 'Forvogn'],
                'vehicle_types' => ['3' => 'Forvogn'],
                'questions' => ['1' => 'Are mirrors folded?'],
                'conditions' => ['10' => 'Trailer present'],
                'tasks' => ['30' => 'Fold mirrors'],
            ],
        ],
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 50,
                    'label' => 'Gateway A',
                    'status' => 'ONLINE',
                    'bindings' => [
                        ['relay_id' => 'relay-1', 'label' => 'Machine relay', 'role' => 'MACHINE'],
                    ],
                ],
            ],
            'relays' => [
                ['relay_id' => 'relay-1', 'name' => 'Machine relay'],
            ],
            'lanes' => [
                [
                    'id' => 7,
                    'relay_slots' => [
                        ['relay_id' => 'relay-1', 'slot' => 'MACHINE'],
                    ],
                ],
            ],
        ],
    ]);

    $nodeIds = array_column($graph['nodes'], 'id');
    $edgeIds = array_column($graph['edges'], 'id');

    expect($nodeIds)->toContain('question:1');
    expect($nodeIds)->toContain('condition:10');
    expect($nodeIds)->toContain('rule:20');
    expect($nodeIds)->toContain('task:30');
    expect($nodeIds)->toContain('vehicle_type:3');
    expect($nodeIds)->toContain('gateway:50');
    expect($nodeIds)->toContain('relay:relay-1');
    expect($edgeIds)->toContain('question-gate:10:1');
    expect($edgeIds)->toContain('task-gate:question:1:30');
    expect($edgeIds)->toContain('scope:vehicle_type:3:question:1');
    expect($edgeIds)->toContain('scope:vehicle_type:3:condition:10');
    expect($edgeIds)->toContain('scope:vehicle_type:3:task:30');
    expect($edgeIds)->toContain('gateway-binding:50:relay-1:0');
    expect($edgeIds)->toContain('task-service:30:MACHINE:50:relay-1:0');
    expect($edgeIds)->toContain('relay-lane:relay-1:7:MACHINE');

    $taskNode = array_values(array_filter(
        $graph['nodes'],
        static fn(array $node): bool => ($node['id'] ?? null) === 'task:30'
    ))[0] ?? null;
    $bindingNode = array_values(array_filter(
        $graph['nodes'],
        static fn(array $node): bool => ($node['id'] ?? null) === 'binding:50:relay-1:0'
    ))[0] ?? null;

    expect($taskNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']);
    expect($bindingNode['data']['raw']['services'] ?? [])->toBe(['MACHINE']);
});

it('serializes configurable studio actions with event, gate, scope, and ordering edges', function (): void {
    $service = selfserve_studio_graph_without_constructor();

    $graph = $service->buildGraphFromConfig([
        'schema_version' => 2,
        'questions' => [],
        'conditions' => [
            ['id' => 10, 'name' => 'Machine selected'],
        ],
        'rules' => [],
        'tasks' => [],
        'actions' => [
            [
                'id' => 80,
                'name' => 'Open lane entry',
                'event' => 'wash_start_command',
                'wash_mode' => 'both',
                'operation' => 'open_lane_entrance_port',
                'condition_id' => 10,
                'lane' => 7,
                'order_priority' => 1,
            ],
            [
                'id' => 81,
                'name' => 'Cleaner off',
                'event' => 'wash_start_command',
                'wash_mode' => 'machine',
                'operation' => 'set_cleaner_relay',
                'relay_state' => false,
                'lane' => 7,
                'order_priority' => 2,
            ],
        ],
    ], [
        'lookups' => [
            'labels' => [
                'lanes' => ['7' => 'Lane 7'],
                'conditions' => ['10' => 'Machine selected'],
                'actions' => ['80' => 'Open lane entry', '81' => 'Cleaner off'],
            ],
        ],
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 50,
                    'label' => 'Gateway A',
                    'status' => 'ONLINE',
                    'bindings' => [
                        ['relay_id' => 'ENTRY-7', 'label' => 'Entry relay', 'role' => 'ENTRY', 'services' => ['ENTRY']],
                        ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner relay', 'role' => 'CLEANER', 'services' => ['CLEANER']],
                    ],
                ],
            ],
            'relays' => [
                ['relay_id' => 'ENTRY-7', 'name' => 'Entry relay'],
                ['relay_id' => 'CLEAN-7', 'name' => 'Cleaner relay'],
            ],
            'lanes' => [
                [
                    'id' => 7,
                    'relay_slots' => [
                        ['relay_id' => 'ENTRY-7', 'slot' => 'ENTRY'],
                        ['relay_id' => 'CLEAN-7', 'slot' => 'CLEANER'],
                    ],
                ],
            ],
        ],
    ]);

    $nodeIds = array_column($graph['nodes'], 'id');
    $edgeIds = array_column($graph['edges'], 'id');
    $actionNode = array_values(array_filter(
        $graph['nodes'],
        static fn(array $node): bool => ($node['id'] ?? null) === 'action:81'
    ))[0] ?? null;

    expect($nodeIds)->toContain('action:80')
        ->and($nodeIds)->toContain('action:81')
        ->and($edgeIds)->toContain('action-event:wash_start_command:80')
        ->and($edgeIds)->toContain('action-gate:10:80')
        ->and($edgeIds)->toContain('action-order:wash_start_command:80:81')
        ->and($edgeIds)->toContain('action-relay:80:ENTRY-7:ENTRY:7')
        ->and($edgeIds)->toContain('action-relay:81:CLEAN-7:CLEANER:7')
        ->and($edgeIds)->toContain('scope:lane:7:action:80')
        ->and($actionNode['data']['action_label'])->toBe('Turn OFF CLEANER')
        ->and($actionNode['data']['relay_role'])->toBe('CLEANER');
});

it('validates action configuration and keeps warnings non-blocking', function (): void {
    $versioning = new class extends selfserve_config_versioning {
        public function __construct()
        {
        }
    };

    $validation = $versioning->validateConfig([
        'schema_version' => 2,
        'questions' => [
            ['id' => 1, 'question' => 'Machine selected?'],
        ],
        'conditions' => [
            [
                'id' => 10,
                'name' => 'Gate',
                'expression' => [
                    'type' => 'group',
                    'operator' => 'ALL',
                    'children' => [
                        ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'],
                    ],
                ],
            ],
        ],
        'rules' => [],
        'tasks' => [],
        'actions' => [
            [
                'id' => 80,
                'name' => 'Manual machine start action',
                'event' => 'machine_start_triggered',
                'wash_mode' => 'manual',
                'operation' => 'set_machine_relay',
                'condition_id' => 10,
                'relay_state' => true,
                'options' => ['failure_policy' => 'block'],
            ],
        ],
    ]);

    expect($validation['valid'])->toBeTrue()
        ->and($validation['stats']['actions'])->toBe(1)
        ->and(implode("\n", $validation['warnings']))->toContain('uses manual mode for the machine-start event');
});

it('serializes v2 condition expressions without standalone rule nodes', function (): void {
    $service = selfserve_studio_graph_without_constructor();

    $graph = $service->buildGraphFromConfig([
        'schema_version' => 2,
        'questions' => [
            ['id' => 1, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'lane' => 7, 'product' => 3, 'department' => 2, 'order_priority' => 1],
        ],
        'conditions' => [
            [
                'id' => 10,
                'name' => 'Ready',
                'lane' => 7,
                'product' => 3,
                'department' => 2,
                'expression' => [
                    'type' => 'group',
                    'operator' => 'ALL',
                    'children' => [
                        ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'],
                    ],
                ],
            ],
        ],
        'rules' => [
            ['id' => 99, 'condition_id' => 10, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 1],
        ],
        'tasks' => [],
    ], [
        'lookups' => [
            'labels' => [
                'questions' => ['1' => 'Are mirrors folded?'],
                'conditions' => ['10' => 'Ready'],
            ],
        ],
    ]);

    $nodeIds = array_column($graph['nodes'], 'id');
    $edgeIds = array_column($graph['edges'], 'id');
    $conditionNode = array_values(array_filter(
        $graph['nodes'],
        static fn(array $node): bool => ($node['id'] ?? null) === 'condition:10'
    ))[0] ?? null;

    expect($nodeIds)->toContain('condition:10');
    expect($nodeIds)->not->toContain('rule:99');
    expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.0'), 0, 8));
    expect($conditionNode['data']['expression_summary'] ?? null)->toContain('Question 1');
});

it('serializes branch and case condition expression dependencies', function (): void {
    $service = selfserve_studio_graph_without_constructor();

    $graph = $service->buildGraphFromConfig([
        'schema_version' => 2,
        'questions' => [
            ['id' => 1, 'question' => 'Has booking?'],
            ['id' => 2, 'question' => 'Allowed?'],
        ],
        'conditions' => [
            [
                'id' => 10,
                'name' => 'Branch condition',
                'expression' => [
                    'type' => 'branch',
                    'branches' => [
                        [
                            'kind' => 'if',
                            'when' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 1, 'operator' => 'IS_TRUE'],
                            'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'],
                        ],
                        [
                            'kind' => 'else',
                            'else' => true,
                            'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_FALSE'],
                        ],
                    ],
                ],
            ],
            [
                'id' => 20,
                'name' => 'Case condition',
                'expression' => [
                    'type' => 'case',
                    'subject_type' => 'condition',
                    'subject_id' => 10,
                    'cases' => [
                        [
                            'value' => true,
                            'then' => ['type' => 'predicate', 'subject_type' => 'question', 'subject_id' => 2, 'operator' => 'IS_TRUE'],
                        ],
                    ],
                ],
            ],
        ],
        'rules' => [],
        'tasks' => [],
    ]);

    $edgeIds = array_column($graph['edges'], 'id');
    $conditionNode = array_values(array_filter(
        $graph['nodes'],
        static fn(array $node): bool => ($node['id'] ?? null) === 'condition:20'
    ))[0] ?? null;

    expect($edgeIds)->toContain('expression:10:question:1:' . substr(md5('0.b0.when'), 0, 8))
        ->and($edgeIds)->toContain('expression:10:question:2:' . substr(md5('0.b0.then'), 0, 8))
        ->and($edgeIds)->toContain('expression:20:condition:10:' . substr(md5('0.case'), 0, 8))
        ->and($edgeIds)->toContain('expression:20:question:2:' . substr(md5('0.c0.then'), 0, 8))
        ->and($conditionNode['data']['expression_summary'] ?? null)->toContain('Case Condition 10');
});

it('keeps runtime on published v2 configs and leaves draft JSON as the studio edit surface', function (): void {
    $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName());
    $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName());

    expect($washFlowSource)->toContain('getPublishedV2Config($departmentId)');
    expect($washFlowSource)->toContain("\$configSource = \$publishedConfigPayload === null ? 'legacy' : 'published';");
    expect($studioGraphSource)->toContain('$draftObject->config_json->set($config);');
    expect($studioGraphSource)->toContain('Standalone rule operations are not supported in self-serve rules v2.');
    expect($studioGraphSource)->toContain('upsert_path');
});

it('upserts path editor answers into generated condition and task config rows', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $method = new ReflectionMethod(selfserve_studio_graph::class, 'applyConfigOperation');
    $config = [
        'schema_version' => 2,
        'questions' => [
            ['id' => 11, 'question' => 'Machine wash is allowed', 'order_priority' => 1],
            ['id' => 12, 'question' => 'Trailer present', 'order_priority' => 2],
        ],
        'conditions' => [],
        'rules' => [],
        'tasks' => [],
        'actions' => [],
        'v2_meta' => ['next_ids' => ['condition' => 100, 'task' => 200]],
    ];

    $method->invokeArgs($service, [6, &$config, [
        'action' => 'upsert_path',
        'entity' => 'path',
        'data' => [
            'path_key' => 'allowed_front',
            'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
            'answers' => [
                ['question_id' => 11, 'value' => true],
                ['question_id' => 12, 'value' => false],
            ],
            'result' => [
                'machine_allowed' => true,
                'task' => 'Set program',
                'services' => ['MACHINE', 'PROGRAM_PICKER'],
                'buttons' => ['program_picker', 'reset', 2, 'start'],
                'dynamic_images_vehicle_type' => 3,
                'tasks' => [
                    [
                        'task' => 'Set program',
                        'services' => ['MACHINE', 'PROGRAM_PICKER'],
                        'buttons' => ['program_picker'],
                        'dynamic_images_vehicle_type' => 3,
                    ],
                    [
                        'task' => 'Press reset',
                        'services' => ['MACHINE'],
                        'buttons' => ['reset'],
                    ],
                    [
                        'task' => 'Press machine button 2',
                        'services' => ['MACHINE'],
                        'buttons' => [2],
                    ],
                    [
                        'task' => 'Press start',
                        'services' => ['MACHINE'],
                        'buttons' => ['start'],
                    ],
                ],
            ],
        ],
    ]]);

    $condition = $config['conditions'][0] ?? [];
    $tasks = array_values($config['tasks'] ?? []);
    $pathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];
    $validation = (new class extends selfserve_config_versioning {
        public function __construct()
        {
        }
    })->validateConfig($config);

    expect($condition['generated_by'])->toBe('path_editor')
        ->and($condition['path_key'])->toBe('allowed_front')
        ->and($condition['lane'])->toBe(7)
        ->and($condition['product'])->toBe(2)
        ->and($condition['machine_type_id'])->toBe(1001)
        ->and($condition['expression']['children'])->toHaveCount(2)
        ->and($condition['expression']['children'][0]['operator'])->toBe('IS_TRUE')
        ->and($condition['expression']['children'][1]['operator'])->toBe('IS_FALSE')
        ->and($tasks)->toHaveCount(4)
        ->and(array_column($tasks, 'task'))->toBe(['Set program', 'Press reset', 'Press machine button 2', 'Press start'])
        ->and(array_column($tasks, 'order_priority'))->toBe([10, 20, 30, 40])
        ->and(array_column($tasks, 'gate_ref_id'))->toBe([(int)$condition['id'], (int)$condition['id'], (int)$condition['id'], (int)$condition['id']])
        ->and($tasks[0]['generated_by'])->toBe('path_editor')
        ->and($tasks[0]['gate_type'])->toBe('CONDITION')
        ->and($tasks[0]['services'])->toBe(['MACHINE', 'PROGRAM_PICKER'])
        ->and($tasks[0]['buttons'])->toBe(['program_picker'])
        ->and($tasks[0]['dynamic_images_vehicle_type'])->toBe(3)
        ->and($tasks[1]['buttons'])->toBe(['reset'])
        ->and($tasks[2]['buttons'])->toBe([2])
        ->and($tasks[3]['buttons'])->toBe(['start'])
        ->and($pathMeta['condition_id'])->toBe((int)$condition['id'])
        ->and($pathMeta['task_id'])->toBe((int)$tasks[0]['id'])
        ->and($pathMeta['task_ids'])->toBe(array_map(static fn(array $task): int => (int)$task['id'], $tasks))
        ->and($pathMeta['result']['buttons'])->toBe(['program_picker', 'reset', 2, 'start'])
        ->and($pathMeta['path_signature'])->not->toBe('')
        ->and($validation['valid'])->toBeTrue();

    $method->invokeArgs($service, [6, &$config, [
        'action' => 'upsert_path',
        'entity' => 'path',
        'data' => [
            'path_key' => 'allowed_front',
            'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
            'answers' => [
                ['question_id' => 11, 'value' => true],
                ['question_id' => 12, 'value' => false],
            ],
            'result' => [
                'machine_allowed' => true,
                'task' => 'Set program',
                'services' => ['MACHINE', 'PROGRAM_PICKER'],
                'buttons' => ['program_picker', 'start'],
                'dynamic_images_vehicle_type' => 3,
                'tasks' => [
                    ['task' => 'Set program', 'services' => ['MACHINE', 'PROGRAM_PICKER'], 'buttons' => ['program_picker'], 'dynamic_images_vehicle_type' => 3],
                    ['task' => 'Press start', 'services' => ['MACHINE'], 'buttons' => ['start']],
                ],
            ],
        ],
    ]]);

    $updatedTasks = array_values(array_filter(
        $config['tasks'] ?? [],
        static fn(array $task): bool => ($task['path_key'] ?? '') === 'allowed_front'
    ));
    $updatedPathMeta = $config['v2_meta']['path_editor']['paths']['allowed_front'] ?? [];

    expect($updatedTasks)->toHaveCount(2)
        ->and(array_column($updatedTasks, 'id'))->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']])
        ->and(array_column($updatedTasks, 'task'))->toBe(['Set program', 'Press start'])
        ->and($updatedPathMeta['task_ids'])->toBe([(int)$tasks[0]['id'], (int)$tasks[1]['id']]);

    $method->invokeArgs($service, [6, &$config, [
        'action' => 'upsert_path',
        'entity' => 'path',
        'data' => [
            'path_key' => 'legacy_front',
            'scope' => ['lane_id' => 7, 'vehicle_type_id' => 2, 'machine_type_id' => 1001],
            'answers' => [
                ['question_id' => 11, 'value' => false],
            ],
            'result' => [
                'machine_allowed' => true,
                'task' => 'Legacy start',
                'services' => ['MACHINE'],
                'buttons' => ['start'],
            ],
        ],
    ]]);

    $legacyPathMeta = $config['v2_meta']['path_editor']['paths']['legacy_front'] ?? [];
    $legacyTask = array_values(array_filter(
        $config['tasks'] ?? [],
        static fn(array $task): bool => ($task['path_key'] ?? '') === 'legacy_front'
    ))[0] ?? [];

    expect($legacyTask['task'])->toBe('Legacy start')
        ->and($legacyTask['buttons'])->toBe(['start'])
        ->and($legacyPathMeta['task_id'])->toBe((int)$legacyTask['id'])
        ->and($legacyPathMeta['task_ids'])->toBe([(int)$legacyTask['id']]);
});

it('surfaces task attachments in studio graph, simulator, and flow responses', function (): void {
    $washFlowSource = file_get_contents((new ReflectionClass(selfserve_wash_flow::class))->getFileName());
    $studioGraphSource = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName());
    $attachmentPayloadSource = file_get_contents(WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php');

    expect($washFlowSource)->toContain("require_once WD . '/modules/selfserve/classes/selfserve_task_attachment_payloads.php';")
        ->and($washFlowSource)->toContain('(new selfserve_task_attachment_payloads())->attachToTasks(')
        ->and($washFlowSource)->toContain("'attachments' => \$task['attachments'] ?? []")
        ->and($washFlowSource)->toContain("'attachments' => \$taskAttachments")
        ->and($studioGraphSource)->toContain('$configWithAttachments = $this->withTaskAttachments($config);')
        ->and($studioGraphSource)->toContain('$task[\'attachments\'] = is_array($task[\'attachments\'] ?? null) ? array_values($task[\'attachments\']) : [];')
        ->and($attachmentPayloadSource)->toContain("private const OBJECT_TYPE = 'department_selfserve_tasks';")
        ->and($attachmentPayloadSource)->toContain('listMany(self::OBJECT_TYPE, $taskIds)')
        ->and($attachmentPayloadSource)->toContain('generateDirectDownloadUrl($fileName)');
});

it('derives studio vehicle type lookup rows from selectable wash products', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $method = new ReflectionMethod(selfserve_studio_graph::class, 'vehicleTypeRowsFromProducts');

    $vehicleTypes = $method->invoke($service, [
        ['id' => 3, 'name' => 'Forvogn', 'description' => 'Front vehicle', 'is_wash' => 1, 'subscription_allowed' => 1, 'order_priority' => 10],
        ['id' => 4, 'name' => 'Trækker', 'description' => 'Tractor unit', 'is_wash' => '1', 'subscription_allowed' => '1', 'order_priority' => 20],
        ['id' => 5, 'name' => 'Addon', 'description' => '', 'is_wash' => 0, 'subscription_allowed' => 1, 'order_priority' => 30],
        ['id' => 6, 'name' => 'Internal wash', 'description' => '', 'is_wash' => 1, 'subscription_allowed' => 0, 'order_priority' => 40],
    ]);

    expect(array_column($vehicleTypes, 'id'))->toBe([3, 4]);
    expect(array_column($vehicleTypes, 'label'))->toBe(['Forvogn', 'Trækker']);
    expect($vehicleTypes[0]['product'])->toBe(3);
    expect($vehicleTypes[0]['product_id'])->toBe(3);
    expect($vehicleTypes[0]['source'])->toBe('products');
});

it('exposes dynamic images and referenced machine types as studio lookup choices', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $dynamicImages = new ReflectionMethod(selfserve_studio_graph::class, 'dynamicImageRowsFromLanes');
    $machineTypes = new ReflectionMethod(selfserve_studio_graph::class, 'addReferencedMachineTypeRows');

    $dynamicImageRows = $dynamicImages->invoke($service, [
        ['id' => 7, 'label' => 'Lane 7', 'dynamic_image_id' => 1],
        ['id' => 8, 'label' => 'Lane 8', 'dynamic_image_id' => 9],
    ]);
    $machineTypeRows = $machineTypes->invoke(
        $service,
        [
            ['id' => 1001, 'name' => 'Portal', 'label' => 'Portal'],
        ],
        [
            ['id' => 7, 'machine_type_id' => 2002],
        ],
        [
            'conditions' => [
                ['id' => 21, 'machine_type_id' => 3003],
            ],
            'tasks' => [
                ['id' => 41, 'machine_type_id' => 1001],
            ],
        ]
    );

    expect(array_column($dynamicImageRows, 'id'))->toBe([1, 9]);
    expect($dynamicImageRows[0]['label'])->toBe('Machine 1');
    expect($dynamicImageRows[1]['label'])->toBe('Dynamic image 9');
    expect(array_column($machineTypeRows, 'id'))->toBe([1001, 2002, 3003]);
    expect($machineTypeRows[0]['label'])->toBe('Portal');
    expect($machineTypeRows[1]['label'])->toBe('Machine type 2002');
    expect($machineTypeRows[2]['label'])->toBe('Machine type 3003');
});

it('keeps lane management fields on lane scope nodes', function (): void {
    $service = selfserve_studio_graph_without_constructor();

    $graph = $service->buildGraphFromConfig([
        'questions' => [],
        'conditions' => [],
        'rules' => [],
        'tasks' => [],
    ], [
        'lookups' => [
            'lanes' => [
                [
                    'id' => 7,
                    'department' => 6,
                    'name' => 'Lane 7',
                    'label' => 'Lane 7',
                    'relay_machine_id' => 'M-7',
                    'machine_type_id' => 1001,
                    'dynamic_image_id' => 1,
                    'selfserve_enabled' => true,
                ],
            ],
            'machine_types' => [['id' => 1001, 'label' => 'Portal']],
            'dynamic_images' => [['id' => 1, 'label' => 'Machine 1']],
            'labels' => [
                'lanes' => ['7' => 'Lane 7'],
                'machine_types' => ['1001' => 'Portal'],
                'dynamic_images' => ['1' => 'Machine 1'],
            ],
        ],
    ]);

    $laneNode = array_values(array_filter(
        $graph['nodes'],
        static fn(array $node): bool => ($node['id'] ?? null) === 'lane:7'
    ))[0] ?? null;

    expect($laneNode)->not->toBeNull()
        ->and($laneNode['data']['raw']['relay_machine_id'])->toBe('M-7')
        ->and($laneNode['data']['raw']['machine_type_id'])->toBe(1001)
        ->and($laneNode['data']['raw']['dynamic_image_id'])->toBe(1)
        ->and($laneNode['data']['raw']['selfserve_enabled'])->toBeTrue();
});

it('routes studio lane graph operations through department_lanes', function (): void {
    $source = file_get_contents((new ReflectionClass(selfserve_studio_graph::class))->getFileName());

    expect($source)->toContain("if (\$entity === 'lane')")
        ->and($source)->toContain('private function applyLaneOperation')
        ->and($source)->toContain('private function createLane')
        ->and($source)->toContain('private function updateLane')
        ->and($source)->toContain('INSERT INTO department_lanes')
        ->and($source)->toContain("'dynamic_image_id'")
        ->and($source)->toContain("'selfserve_enabled'")
        ->and($source)->toContain('normalizeLaneField')
        ->and($source)->toContain('normalizeSelfServeEnabledValue')
        ->and($source)->toContain('disableSelfServeRelaysBestEffort');
});

it('applies saved layout without changing graph semantics', function (): void {
    $service = selfserve_studio_graph_without_constructor();

    $graph = $service->buildGraphFromConfig([
        'questions' => [
            ['id' => 1, 'question' => 'Question', 'order_priority' => 1],
        ],
        'conditions' => [],
        'rules' => [],
        'tasks' => [],
    ], [
        'lookups' => [
            'labels' => [],
        ],
    ], [
        'nodes' => [
            'question:1' => ['x' => 123, 'y' => 456],
        ],
    ]);

    $questionNode = array_values(array_filter(
        $graph['nodes'],
        static fn(array $node): bool => ($node['id'] ?? null) === 'question:1'
    ))[0] ?? null;

    expect($questionNode)->not->toBeNull();
    expect($questionNode['position'])->toBe(['x' => 123.0, 'y' => 456.0]);
});

it('keeps layout loading compatible with native PDO named placeholders', function (): void {
    $method = new ReflectionMethod(selfserve_studio_graph::class, 'loadLayout');
    $source = implode('', array_slice(
        file((string)$method->getFileName()) ?: [],
        $method->getStartLine() - 1,
        $method->getEndLine() - $method->getStartLine() + 1
    ));

    expect($source)->not->toContain('user_id = :user_id OR user_id IS NULL');
    expect($source)->not->toContain('user_id = :user_id THEN 0 ELSE 1');
    expect($source)->toContain(':user_id_filter');
    expect($source)->toContain(':user_id_sort');
});

it('builds guided simulator debug payload with blockers and canvas annotations', function (): void {
    $service = selfserve_wash_flow_without_constructor();

    $debug = $service->buildStudioDebugPayload(6, [
        'lane' => ['id' => 7, 'name' => 'Lane 7'],
        'machine_type' => ['id' => 1001, 'name' => 'Portal'],
        'vehicle' => null,
        'reg' => 'TEST123',
        'customer_number' => null,
        'vehicle_type_id' => 2,
        'answers' => [11 => null],
        'answer_sources' => [11 => 'override'],
        'questions' => [],
        'tasks' => [],
        'allowed_services' => [],
        'machine_available' => false,
        'all_visible_questions_answered' => false,
        'allowed' => false,
        'config_version_id' => 90,
        'config_source' => 'draft',
        'evaluation_trace' => [
            'visible_question_ids' => [11],
            'visibility_condition_results' => [21 => true],
            'condition_results' => [21 => true],
            'task_gates' => [
                ['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false],
            ],
        ],
        'debug_candidates' => [
            'questions' => [
                ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => 21, 'order_priority' => 1],
            ],
            'conditions' => [
                ['id' => 21, 'name' => 'Trailer present', 'condition_id' => null],
            ],
            'rules' => [
                ['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'],
            ],
            'tasks' => [
                ['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => [1], 'order_priority' => 1],
            ],
        ],
    ], [
        'lookups' => [
            'labels' => [
                'departments' => ['6' => 'Roskilde'],
                'lanes' => ['7' => 'Lane 7'],
                'vehicle_types' => ['2' => 'Forvogn'],
                'machine_types' => ['1001' => 'Portal'],
                'questions' => ['11' => 'Are mirrors folded?'],
                'conditions' => ['21' => 'Trailer present'],
                'rules' => ['31' => 'Mirror answer'],
                'tasks' => ['41' => 'Fold mirrors'],
            ],
        ],
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 701,
                    'label' => 'Roskilde Edge',
                    'status' => 'ONLINE',
                    'bindings' => [
                        ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
                    ],
                ],
            ],
        ],
        'graph' => [
            'edges' => [
                ['id' => 'task-gate:question:11:41', 'source' => 'question:11', 'target' => 'task:41', 'label' => 'unlocks'],
            ],
        ],
    ]);

    expect($debug['summary']['status'])->toBe('blocked')
        ->and($debug['parameters']['config_source'])->toBe('draft')
        ->and($debug['questions'][0]['state'])->toBe('missing')
        ->and($debug['questions'][0]['answer_source'])->toBe('override')
        ->and($debug['tasks'][0]['state'])->toBe('blocked')
        ->and($debug['tasks'][0]['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual missing.')
        ->and($debug['tasks'][0]['causes'][0])->toMatchArray([
            'kind' => 'question',
            'id' => 11,
            'label' => 'Are mirrors folded?',
            'expected' => true,
            'actual' => null,
        ])
        ->and($debug['dynamic_image_buttons'][0])->toMatchArray([
            'kind' => 'dynamic_image_button',
            'label' => '1',
            'state' => 'hidden',
            'task_id' => 41,
        ])
        ->and(array_column($debug['decisions'], 'kind'))->toContain('question')
        ->and(array_column($debug['decisions'], 'kind'))->toContain('task')
        ->and(array_column($debug['decisions'], 'kind'))->toContain('signal')
        ->and(array_column($debug['decisions'], 'kind'))->toContain('dynamic_image_button')
        ->and(array_column($debug['recommendations'], 'title'))->toContain('Answer required questions')
        ->and(array_column($debug['recommendations'], 'title'))->toContain('Configure lane machine relay')
        ->and($debug['graph_annotations']['nodes']['question:11']['state'])->toBe('warning')
        ->and($debug['graph_annotations']['nodes']['lane:7']['state'])->toBe('error');
});

it('uses program picker button numbers as thumb selectors in simulator debug decisions', function (): void {
    $service = selfserve_wash_flow_without_constructor();

    $debug = $service->buildStudioDebugPayload(6, [
        'lane' => [
            'id' => 7,
            'department' => 6,
            'name' => 'Lane 7',
            'relay_machine_id' => 'M-7',
            'relay_machine_program_picker_id' => 'PICKER-7',
        ],
        'machine_type' => ['id' => 1001, 'name' => 'Portal'],
        'vehicle' => null,
        'reg' => 'TEST123',
        'customer_number' => null,
        'vehicle_type_id' => 4,
        'answers' => [],
        'questions' => [],
        'tasks' => [
            ['id' => 41, 'task' => 'Choose program', 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
            ['id' => 42, 'task' => 'Press first button', 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
        ],
        'allowed_services' => ['PROGRAM_PICKER', 'MACHINE'],
        'machine_available' => true,
        'all_visible_questions_answered' => true,
        'allowed' => true,
        'config_version_id' => 90,
        'config_source' => 'draft',
        'evaluation_trace' => [
            'visible_question_ids' => [],
            'visibility_condition_results' => [],
            'condition_results' => [],
            'task_gates' => [
                ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
                ['task_id' => 42, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
            ],
        ],
        'debug_candidates' => [
            'questions' => [],
            'conditions' => [],
            'rules' => [],
            'tasks' => [
                ['id' => 41, 'task' => 'Choose program', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['PROGRAM_PICKER'], 'buttons' => [3], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 1],
                ['id' => 42, 'task' => 'Press first button', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [0], 'dynamic_images_vehicle_type' => 4, 'order_priority' => 2],
            ],
        ],
    ], [
        'lookups' => [
            'labels' => [
                'departments' => ['6' => 'Roskilde'],
                'lanes' => ['7' => 'Lane 7'],
                'vehicle_types' => ['4' => 'Program 4'],
                'machine_types' => ['1001' => 'Portal'],
                'tasks' => ['41' => 'Choose program', '42' => 'Press first button'],
            ],
        ],
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 701,
                    'label' => 'Roskilde Edge',
                    'status' => 'ONLINE',
                    'bindings' => [
                        ['relay_id' => 'PICKER-7', 'label' => 'Program picker relay', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']],
                        ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
                    ],
                ],
            ],
        ],
    ]);

    $dynamicImageDecisions = array_values(array_filter(
        $debug['decisions'],
        static fn(array $decision): bool => ($decision['kind'] ?? null) === 'dynamic_image_button'
    ));

    expect(array_column($debug['dynamic_image_buttons'], 'button'))->toBe(['program_picker', 0])
        ->and(array_column($debug['dynamic_image_buttons'], 'label'))->toBe(['Program picker', '0'])
        ->and(array_column($dynamicImageDecisions, 'label'))->toBe(['Program picker', '0']);
});

it('adds exact hidden question, skipped action, signal, and button decision causes', function (): void {
    $service = selfserve_wash_flow_without_constructor();

    $debug = $service->buildStudioDebugPayload(6, [
        'lane' => [
            'id' => 7,
            'department' => 6,
            'name' => 'Lane 7',
            'relay_machine_id' => 'M-7',
        ],
        'machine_type' => ['id' => 1001, 'name' => 'Portal'],
        'vehicle' => null,
        'reg' => 'TEST123',
        'customer_number' => null,
        'vehicle_type_id' => 2,
        'answers' => [11 => false],
        'answer_sources' => [11 => 'override'],
        'questions' => [],
        'tasks' => [],
        'allowed_services' => [],
        'machine_available' => true,
        'all_visible_questions_answered' => true,
        'allowed' => false,
        'config_version_id' => 90,
        'config_source' => 'draft',
        'evaluation_trace' => [
            'visible_question_ids' => [11],
            'visibility_condition_results' => [21 => false],
            'condition_results' => [21 => false],
            'visibility_expression_traces' => [
                21 => [
                    'type' => 'condition',
                    'condition_id' => 21,
                    'result' => false,
                    'expression' => [
                        'type' => 'group',
                        'operator' => 'ALL',
                        'result' => false,
                        'children' => [
                            [
                                'type' => 'predicate',
                                'subject_type' => 'question',
                                'subject_id' => 11,
                                'operator' => 'IS_TRUE',
                                'actual_value' => false,
                                'result' => false,
                                'reason' => 'Predicate did not pass.',
                            ],
                        ],
                        'reason' => 'Group did not pass.',
                    ],
                ],
            ],
            'condition_expression_traces' => [
                21 => [
                    'type' => 'condition',
                    'condition_id' => 21,
                    'result' => false,
                    'expression' => [
                        'type' => 'predicate',
                        'subject_type' => 'question',
                        'subject_id' => 11,
                        'operator' => 'IS_TRUE',
                        'actual_value' => false,
                        'result' => false,
                        'reason' => 'Predicate did not pass.',
                    ],
                ],
            ],
            'task_gates' => [
                ['task_id' => 41, 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'satisfied' => false],
            ],
        ],
        'debug_candidates' => [
            'questions' => [
                ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1],
                ['id' => 12, 'question' => 'Is the lift lowered?', 'condition_id' => 21, 'order_priority' => 2],
            ],
            'conditions' => [
                ['id' => 21, 'name' => 'Trailer present', 'condition_id' => null],
            ],
            'rules' => [
                ['id' => 31, 'condition_id' => 21, 'type' => 'IS_TRUE', 'object_type' => 'question', 'object_id' => 11, 'name' => 'Mirror answer'],
            ],
            'tasks' => [
                ['id' => 41, 'task' => 'Fold mirrors', 'gate_type' => 'QUESTION', 'gate_ref_id' => 11, 'services' => ['MACHINE'], 'buttons' => ['start'], 'order_priority' => 1],
            ],
            'actions' => [
                [
                    'id' => 81,
                    'name' => 'Open entry on start',
                    'event' => 'wash_start_command',
                    'wash_mode' => 'both',
                    'operation' => 'open_lane_entrance_port',
                    'condition_id' => 21,
                    'order_priority' => 1,
                ],
            ],
            'visible_answers' => [11 => false],
        ],
    ], [
        'lookups' => [
            'labels' => [
                'departments' => ['6' => 'Roskilde'],
                'lanes' => ['7' => 'Lane 7'],
                'vehicle_types' => ['2' => 'Forvogn'],
                'machine_types' => ['1001' => 'Portal'],
                'questions' => ['11' => 'Are mirrors folded?', '12' => 'Is the lift lowered?'],
                'conditions' => ['21' => 'Trailer present'],
                'tasks' => ['41' => 'Fold mirrors'],
            ],
        ],
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 701,
                    'label' => 'Roskilde Edge',
                    'status' => 'ONLINE',
                    'bindings' => [
                        ['relay_id' => 'M-7', 'label' => 'Machine relay', 'role' => 'MACHINE', 'services' => ['MACHINE']],
                    ],
                ],
            ],
        ],
    ]);

    $hiddenQuestion = array_values(array_filter($debug['questions'], static fn(array $question): bool => (int)$question['id'] === 12))[0] ?? [];
    $taskDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'task'))[0] ?? [];
    $actionDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'action'))[0] ?? [];
    $signalDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'signal' && ($decision['state'] ?? '') === 'blocked'))[0] ?? [];
    $buttonDecision = array_values(array_filter($debug['decisions'], static fn(array $decision): bool => ($decision['kind'] ?? '') === 'dynamic_image_button'))[0] ?? [];

    expect($hiddenQuestion['state'])->toBe('hidden')
        ->and($hiddenQuestion['reason'])->toBe('Question Is the lift lowered? hidden because visibility condition Trailer present expected true, actual false.')
        ->and($debug['conditions'][0]['causes'][0])->toMatchArray([
            'kind' => 'question',
            'id' => 11,
            'label' => 'Are mirrors folded?',
            'expected' => true,
            'actual' => false,
        ])
        ->and($taskDecision['reason'])->toBe('Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual false.')
        ->and($actionDecision['state'])->toBe('skipped')
        ->and($actionDecision['causes'][0])->toMatchArray([
            'kind' => 'condition',
            'id' => 21,
            'label' => 'Trailer present',
            'expected' => true,
            'actual' => false,
        ])
        ->and($signalDecision['causes'][0]['reason'])->toContain('blocked')
        ->and($buttonDecision['state'])->toBe('hidden')
        ->and($buttonDecision['causes'][0]['label'])->toBe('Fold mirrors');
});

it('projects visible question answer paths into grouped task service and signal outcomes', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $simulate = function (array $overrides): array {
        $answers = [];
        foreach ($overrides as $entry) {
            $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
        }

        $mirrorAnswer = $answers[11] ?? null;
        $liftAnswer = $answers[12] ?? null;
        $liftVisible = $mirrorAnswer === true;
        $allowed = $mirrorAnswer === true && $liftAnswer === true;

        return [
            'allowed' => $allowed,
            'questions' => array_values(array_filter([
                ['id' => 11, 'question' => 'Are mirrors folded?', 'answer' => $mirrorAnswer],
                $liftVisible ? ['id' => 12, 'question' => 'Is the lift lowered?', 'answer' => $liftAnswer] : null,
            ])),
            'tasks' => $allowed ? [
                ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => ['start']],
            ] : [],
            'allowed_services' => $allowed ? ['MACHINE'] : [],
            'debug' => [
                'questions' => [
                    [
                        'id' => 11,
                        'node_id' => 'question:11',
                        'label' => 'Are mirrors folded?',
                        'visible' => true,
                        'answer' => $mirrorAnswer,
                    ],
                    [
                        'id' => 12,
                        'node_id' => 'question:12',
                        'label' => 'Is the lift lowered?',
                        'visible' => $liftVisible,
                        'answer' => $liftVisible ? $liftAnswer : null,
                    ],
                ],
                'tasks' => [
                    [
                        'id' => 41,
                        'node_id' => 'task:41',
                        'label' => 'Start machine',
                        'active' => $allowed,
                        'services' => ['MACHINE'],
                        'buttons' => ['start'],
                        'order_priority' => 1,
                    ],
                ],
                'signal_timeline' => [
                    [
                        'sequence' => 1,
                        'runtime_stage' => 'eligibility_sync',
                        'signal_type' => 'session_event',
                        'relay_role' => 'SESSION',
                        'source' => 'none',
                        'predicted_status' => $allowed ? 'sent' : 'skipped',
                        'payload' => ['allowed' => $allowed],
                    ],
                    [
                        'sequence' => 2,
                        'runtime_stage' => 'machine_start_signal',
                        'signal_type' => 'shelly_event',
                        'relay_role' => 'MACHINE',
                        'relay_id' => 'M-7',
                        'target_binding' => 'binding:701:M-7:0',
                        'target_gateway_label' => 'Roskilde Edge',
                        'source' => 'real',
                        'predicted_status' => $allowed ? 'sent' : 'skipped',
                        'payload' => ['event' => 'input.toggle_on'],
                    ],
                ],
            ],
        ];
    };

    $projection = $service->projectPathOutcomesFromSimulator($simulate, [
        'max_states' => 20,
        'scope' => [
            'department_id' => 6,
            'lane_id' => 7,
            'vehicle_type_id' => 2,
            'config_source' => 'draft',
            'hardware_mode' => 'studio',
        ],
    ]);

    $allowedOutcome = array_values(array_filter(
        $projection['outcomes'],
        static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === true
    ))[0] ?? null;
    $blockedOutcome = array_values(array_filter(
        $projection['outcomes'],
        static fn(array $outcome): bool => ($outcome['allowed'] ?? false) === false
    ))[0] ?? null;

    expect($projection['truncated'])->toBeFalse()
        ->and($projection['summary']['state_count'])->toBe(5)
        ->and($projection['summary']['terminal_path_count'])->toBe(3)
        ->and($projection['summary']['outcome_count'])->toBe(2)
        ->and($projection['summary']['path_sample_count'])->toBe(3)
        ->and($projection['summary']['question_ids'])->toBe([11, 12])
        ->and($projection['paths'])->toHaveCount(3)
        ->and($allowedOutcome)->not->toBeNull()
        ->and($allowedOutcome['path_count'])->toBe(1)
        ->and($allowedOutcome['services'])->toBe(['MACHINE'])
        ->and($allowedOutcome['tasks'][0]['label'])->toBe('Start machine')
        ->and($allowedOutcome['signals'][1]['relay_role'])->toBe('MACHINE')
        ->and($allowedOutcome['node_ids'])->toContain('binding:701:M-7:0')
        ->and($blockedOutcome)->not->toBeNull()
        ->and($blockedOutcome['path_count'])->toBe(2);

    $oneAnswerBlockedSample = array_values(array_filter(
        $blockedOutcome['sample_chains'],
        static fn(array $chain): bool => count((array)($chain['answers'] ?? [])) === 1
    ))[0] ?? null;

    expect($oneAnswerBlockedSample)->not->toBeNull()
        ->and($oneAnswerBlockedSample['answers'][0]['question_id'])->toBe(11)
        ->and($oneAnswerBlockedSample['answers'][0]['answer'])->toBeFalse();

    $allowedPath = array_values(array_filter(
        $projection['paths'],
        static fn(array $path): bool => ($path['allowed'] ?? false) === true
    ))[0] ?? null;

    expect($allowedPath)->not->toBeNull()
        ->and($allowedPath['result'])->toBe('Allowed')
        ->and($allowedPath['answers'])->toHaveCount(2)
        ->and($allowedPath['answers'][0]['question'])->toBe('Are mirrors folded?')
        ->and($allowedPath['services'])->toBe(['MACHINE'])
        ->and($allowedPath['path_signature'])->not->toBe('')
        ->and($allowedPath['result_signature'])->not->toBe('')
        ->and($allowedPath['confirmation_status'])->toBe('unconfirmed')
        ->and($allowedPath['node_ids'])->toContain('question:11')
        ->and($allowedPath['node_ids'])->toContain('task:41');
});

it('marks projected path confirmations confirmed or stale by stable signatures', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $simulate = static function (string $button): callable {
        return static function (array $overrides) use ($button): array {
            $answers = [];
            foreach ($overrides as $entry) {
                $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
            }
            $allowed = ($answers[11] ?? null) === true;

            return [
                'allowed' => $allowed,
                'questions' => [
                    ['id' => 11, 'question' => 'Machine wash is allowed', 'answer' => $answers[11] ?? null],
                ],
                'tasks' => $allowed ? [
                    ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'buttons' => [$button]],
                ] : [],
                'allowed_services' => $allowed ? ['MACHINE'] : [],
                'debug' => [
                    'questions' => [
                        ['id' => 11, 'node_id' => 'question:11', 'label' => 'Machine wash is allowed', 'visible' => true, 'answer' => $answers[11] ?? null],
                    ],
                    'tasks' => [
                        ['id' => 41, 'node_id' => 'task:41', 'label' => 'Start machine', 'active' => $allowed, 'services' => ['MACHINE'], 'buttons' => [$button], 'order_priority' => 1],
                    ],
                    'signal_timeline' => [],
                ],
            ];
        };
    };

    $scope = [
        'department_id' => 6,
        'lane_id' => 7,
        'vehicle_type_id' => 2,
        'config_source' => 'draft',
        'config_version_id' => 90,
        'hardware_mode' => 'studio',
    ];
    $initial = $service->projectPathOutcomesFromSimulator($simulate('start'), ['scope' => $scope]);
    $allowedPath = array_values(array_filter(
        $initial['paths'],
        static fn(array $path): bool => ($path['allowed'] ?? false) === true
    ))[0] ?? [];

    $rows = [[
        'path_signature' => $allowedPath['path_signature'],
        'result_signature' => $allowedPath['result_signature'],
        'answers' => $allowedPath['answers'],
        'result' => ['allowed' => true],
        'scope' => $scope,
        'confirmed_at' => '2026-05-27 10:00:00',
        'confirmed_by' => 9,
    ]];
    $confirmed = $service->projectPathOutcomesFromSimulator($simulate('start'), [
        'scope' => $scope,
        'confirmation_rows' => $rows,
    ]);
    $changed = $service->projectPathOutcomesFromSimulator($simulate('reset'), [
        'scope' => $scope,
        'confirmation_rows' => $rows,
    ]);

    $confirmedAllowed = array_values(array_filter(
        $confirmed['paths'],
        static fn(array $path): bool => ($path['allowed'] ?? false) === true
    ))[0] ?? [];
    $staleAllowed = array_values(array_filter(
        $changed['paths'],
        static fn(array $path): bool => ($path['allowed'] ?? false) === true
    ))[0] ?? [];

    expect($confirmedAllowed['confirmation_status'])->toBe('confirmed')
        ->and($confirmed['summary']['confirmations']['confirmed'])->toBe(1)
        ->and($staleAllowed['confirmation_status'])->toBe('stale')
        ->and($staleAllowed['stale_reason'])->toBe('Result changed since confirmation.')
        ->and($changed['summary']['confirmations']['stale'])->toBe(1);
});

it('treats null path confirmation rows as an empty confirmation set', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $method = new ReflectionMethod(selfserve_studio_graph::class, 'pathOutcomesPayload');

    $payload = $method->invoke(
        $service,
        ['department_id' => 6, 'lane_id' => 7],
        [],
        [],
        [],
        false,
        null,
        0,
        0,
        [],
        [],
        null
    );

    expect($payload['summary']['confirmations']['total'])->toBe(0)
        ->and($payload['confirmations']['removed'])->toBe([]);
});

it('truncates path outcome projection when the state cap is reached', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $simulate = function (array $overrides): array {
        $answers = [];
        foreach ($overrides as $entry) {
            $answers[(int)($entry['question_id'] ?? 0)] = $entry['value'] ?? null;
        }
        $first = $answers[1] ?? null;
        $secondVisible = $first === true;

        return [
            'allowed' => false,
            'questions' => [],
            'tasks' => [],
            'allowed_services' => [],
            'debug' => [
                'questions' => [
                    ['id' => 1, 'node_id' => 'question:1', 'label' => 'First', 'visible' => true, 'answer' => $first],
                    ['id' => 2, 'node_id' => 'question:2', 'label' => 'Second', 'visible' => $secondVisible, 'answer' => $answers[2] ?? null],
                ],
                'tasks' => [],
                'signal_timeline' => [],
            ],
        ];
    };

    $projection = $service->projectPathOutcomesFromSimulator($simulate, ['max_states' => 2]);

    expect($projection['truncated'])->toBeTrue()
        ->and($projection['summary']['state_count'])->toBe(2)
        ->and($projection['warnings'][0])->toContain('truncated at 2 explored state');
});

it('returns more than 200 projected path cases by default', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $projection = $service->projectPathOutcomesFromSimulator(selfserve_question_tree_simulator(8));

    expect($projection['truncated'])->toBeFalse()
        ->and($projection['summary']['state_count'])->toBe(511)
        ->and($projection['summary']['terminal_path_count'])->toBe(256)
        ->and($projection['summary']['path_sample_count'])->toBe(256)
        ->and($projection['paths'])->toHaveCount(256);
});

it('applies the default state cap for wide question trees and reports progress', function (): void {
    $service = selfserve_studio_graph_without_constructor();
    $simulate = selfserve_question_tree_simulator(12);

    $progressEvents = [];
    $projection = $service->projectPathOutcomesFromSimulator($simulate, [
        'progress_interval_states' => 512,
        'progress_callback' => static function (array $partial) use (&$progressEvents): void {
            $progressEvents[] = [
                'percent' => (int)($partial['progress']['percent'] ?? 0),
                'terminal_path_count' => (int)($partial['summary']['terminal_path_count'] ?? 0),
                'path_sample_count' => (int)($partial['summary']['path_sample_count'] ?? 0),
            ];
        },
    ]);

    expect($projection['truncated'])->toBeTrue()
        ->and($projection['summary']['state_count'])->toBe(2048)
        ->and($projection['summary']['question_count'])->toBe(12)
        ->and($projection['summary']['terminal_path_count'])->toBe(1023)
        ->and($projection['summary']['outcome_count'])->toBe(2)
        ->and($projection['summary']['path_sample_count'])->toBe(1023)
        ->and($projection['progress']['complete'])->toBeFalse()
        ->and($projection['progress']['percent'])->toBe(99)
        ->and($projection['paths'])->toHaveCount(1023)
        ->and($projection['paths'][0]['answers'])->toHaveCount(12)
        ->and($projection['paths'][0]['result'])->toBe('Allowed')
        ->and($projection['warnings'][0])->toContain('truncated at 2048 explored state')
        ->and($progressEvents)->not->toBeEmpty()
        ->and($progressEvents[0]['terminal_path_count'])->toBeGreaterThan(0)
        ->and($progressEvents[0]['path_sample_count'])->toBeGreaterThan(0);
});

it('resolves simulator gateway service bindings from lane relay slots', function (): void {
    $service = selfserve_wash_flow_without_constructor();

    $debug = $service->buildStudioDebugPayload(6, [
        'lane' => ['id' => 7, 'name' => 'Lane 7'],
        'machine_type' => ['id' => 1001, 'name' => 'Portal'],
        'vehicle' => null,
        'reg' => 'TEST123',
        'customer_number' => null,
        'vehicle_type_id' => 2,
        'answers' => [11 => true],
        'answer_sources' => [11 => 'override'],
        'questions' => [],
        'tasks' => [
            ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']],
        ],
        'allowed_services' => ['MACHINE'],
        'machine_available' => true,
        'all_visible_questions_answered' => true,
        'allowed' => true,
        'config_version_id' => 90,
        'config_source' => 'draft',
        'evaluation_trace' => [
            'visible_question_ids' => [11],
            'visibility_condition_results' => [],
            'condition_results' => [],
            'task_gates' => [
                ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
            ],
        ],
        'debug_candidates' => [
            'questions' => [
                ['id' => 11, 'question' => 'Are mirrors folded?', 'condition_id' => null, 'order_priority' => 1],
            ],
            'conditions' => [],
            'rules' => [],
            'tasks' => [
                ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'buttons' => [1], 'order_priority' => 1],
            ],
        ],
    ], [
        'lookups' => [
            'labels' => [
                'departments' => ['6' => 'Roskilde'],
                'lanes' => ['7' => 'Lane 7'],
                'vehicle_types' => ['2' => 'Forvogn'],
                'machine_types' => ['1001' => 'Portal'],
                'questions' => ['11' => 'Are mirrors folded?'],
                'tasks' => ['41' => 'Start machine'],
            ],
        ],
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 701,
                    'label' => 'Roskilde Edge',
                    'status' => 'ONLINE',
                    'bindings' => [
                        ['relay_id' => 'M-7', 'label' => 'Machine relay'],
                    ],
                ],
            ],
            'lanes' => [
                [
                    'id' => 7,
                    'relay_slots' => [
                        ['relay_id' => 'M-7', 'slot' => 'MACHINE'],
                    ],
                ],
            ],
        ],
        'graph' => [
            'edges' => [
                ['id' => 'task-service:41:MACHINE:701:M-7:0', 'source' => 'task:41', 'target' => 'binding:701:M-7:0', 'label' => 'MACHINE'],
            ],
        ],
    ]);

    expect($debug['hardware']['missing_service_bindings'])->toBe([])
        ->and($debug['hardware']['service_bindings']['MACHINE'][0]['node_id'])->toBe('binding:701:M-7:0')
        ->and($debug['hardware']['summary'])->toBe('Lane relay and gateway service bindings are ready for the simulated services.')
        ->and($debug['tasks'][0]['relay_bindings'][0]['service'])->toBe('MACHINE')
        ->and(array_column($debug['recommendations'], 'title'))->toBe(['Flow is ready']);
});

it('generates and merges virtual hardware as studio-only relay coverage', function (): void {
    $service = selfserve_virtual_hardware_without_constructor();
    $workspace = [
        'gateways' => [],
        'relays' => [],
        'lanes' => [
            [
                'id' => 7,
                'name' => 'Lane 7',
                'relay_slots' => [
                    ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']],
                    ['slot' => 'EXIT', 'relay_id' => 'EXIT-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']],
                ],
                'binding_coverage' => ['required' => 2, 'bound' => 0, 'missing' => 2, 'state' => 'MISSING'],
            ],
        ],
        'issues' => [
            ['severity' => 'danger', 'code' => 'NO_GATEWAY', 'message' => 'No edge gateway has been claimed for this department.'],
            ['severity' => 'warning', 'code' => 'LANE_BINDING_GAP', 'message' => 'Lane 7 is missing relay bindings.', 'target_type' => 'lane', 'target_id' => 7],
        ],
        'actions' => [],
    ];

    $config = $service->generateFromLanes($workspace);
    $merged = $service->mergeWorkspaceWithConfig($workspace, $config);

    expect($config['bindings'])->toHaveCount(2)
        ->and($merged['virtual']['has_virtual_hardware'])->toBeTrue()
        ->and($merged['gateways'][0]['virtual'])->toBeTrue()
        ->and($merged['gateways'][0]['bindings'][0]['relay_id'])->toBe('M-7')
        ->and($merged['lanes'][0]['binding_coverage']['state'])->toBe('READY')
        ->and($merged['lanes'][0]['relay_slots'][0]['coverage']['virtual'])->toBeTrue()
        ->and(array_column($merged['issues'], 'code'))->toContain('VIRTUAL_HARDWARE_ACTIVE')
        ->and($service->validationWarnings($merged)[0])->toContain('live relay dispatch still requires a real edge gateway');
});

it('renders virtual gateway nodes and task service edges in the studio graph', function (): void {
    $virtual = selfserve_virtual_hardware_without_constructor();
    $graphService = selfserve_studio_graph_without_constructor();
    $workspace = $virtual->mergeWorkspaceWithConfig([
        'gateways' => [],
        'relays' => [],
        'lanes' => [
            [
                'id' => 7,
                'name' => 'Lane 7',
                'relay_slots' => [
                    ['slot' => 'MACHINE', 'relay_id' => 'M-7', 'coverage' => ['covered' => false, 'status' => 'MISSING']],
                ],
                'binding_coverage' => ['required' => 1, 'bound' => 0, 'missing' => 1, 'state' => 'MISSING'],
            ],
        ],
        'issues' => [],
        'actions' => [],
    ], [
        'schema_version' => 1,
        'enabled' => true,
        'gateways' => [['key' => 'virtual-main', 'label' => 'Virtual Studio Gateway', 'status' => 'VIRTUAL']],
        'relays' => [['relay_id' => 'M-7', 'name' => 'Lane 7 MACHINE']],
        'bindings' => [['gateway_key' => 'virtual-main', 'relay_id' => 'M-7', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'label' => 'Lane 7 MACHINE']],
    ]);

    $graph = $graphService->buildGraphFromConfig([
        'questions' => [],
        'conditions' => [],
        'rules' => [],
        'tasks' => [
            ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE'], 'order_priority' => 1],
        ],
    ], [
        'lookups' => [
            'labels' => [
                'tasks' => ['41' => 'Start machine'],
                'lanes' => ['7' => 'Lane 7'],
            ],
        ],
        'gateway_workspace' => $workspace,
    ]);

    $nodeIds = array_column($graph['nodes'], 'id');
    $edgeIds = array_column($graph['edges'], 'id');
    $gatewayNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'gateway:virtual-main'))[0] ?? [];
    $bindingNode = array_values(array_filter($graph['nodes'], static fn(array $node): bool => ($node['id'] ?? '') === 'binding:virtual-main:M-7:0'))[0] ?? [];

    expect($nodeIds)->toContain('gateway:virtual-main')
        ->and($nodeIds)->toContain('binding:virtual-main:M-7:0')
        ->and($edgeIds)->toContain('task-service:41:MACHINE:virtual-main:M-7:0')
        ->and($gatewayNode['data']['raw']['virtual'])->toBeTrue()
        ->and($bindingNode['data']['raw']['virtual'])->toBeTrue();
});

it('inserts configured action signals into the simulator timeline in runtime order', function (): void {
    $service = selfserve_wash_flow_without_constructor();

    $debug = $service->buildStudioDebugPayload(6, [
        'lane' => [
            'id' => 7,
            'department' => 6,
            'name' => 'Lane 7',
            'relay_in_id' => 'ENTRY-7',
            'relay_out_id' => 'EXIT-7',
            'relay_machine_id' => 'M-7',
            'relay_machine_program_picker_id' => 'PICKER-7',
            'relay_machine_cleaner_id' => 'CLEAN-7',
        ],
        'machine_type' => ['id' => 1001, 'name' => 'Portal'],
        'vehicle' => null,
        'reg' => 'TEST123',
        'customer_number' => null,
        'vehicle_type_id' => 2,
        'answers' => [],
        'answer_sources' => [],
        'questions' => [],
        'tasks' => [
            ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']],
        ],
        'allowed_services' => ['MACHINE'],
        'machine_available' => true,
        'all_visible_questions_answered' => true,
        'allowed' => true,
        'config_version_id' => 90,
        'config_source' => 'draft',
        'evaluation_trace' => [
            'visible_question_ids' => [],
            'visibility_condition_results' => [],
            'condition_results' => [21 => true],
            'task_gates' => [
                ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
            ],
        ],
        'debug_candidates' => [
            'questions' => [],
            'conditions' => [
                ['id' => 21, 'name' => 'Gate'],
            ],
            'rules' => [],
            'tasks' => [
                ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1],
            ],
            'actions' => [
                [
                    'id' => 81,
                    'name' => 'Open entry on start',
                    'event' => 'wash_start_command',
                    'wash_mode' => 'both',
                    'operation' => 'open_lane_entrance_port',
                    'condition_id' => 21,
                    'order_priority' => 1,
                    'options' => ['toggle_after_seconds' => 2],
                ],
                [
                    'id' => 82,
                    'name' => 'Program picker off when machine starts',
                    'event' => 'machine_start_triggered',
                    'wash_mode' => 'machine',
                    'operation' => 'set_program_picker_relay',
                    'relay_state' => false,
                    'order_priority' => 1,
                ],
                [
                    'id' => 83,
                    'name' => 'Cleaner off on stop',
                    'event' => 'wash_stop_command',
                    'wash_mode' => 'machine',
                    'operation' => 'set_cleaner_relay',
                    'relay_state' => false,
                    'order_priority' => 1,
                ],
            ],
        ],
    ], [
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 701,
                    'label' => 'Roskilde Edge',
                    'status' => 'ONLINE',
                    'bindings' => [
                        ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']],
                        ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']],
                        ['relay_id' => 'PICKER-7', 'label' => 'Program picker', 'role' => 'PROGRAM_PICKER', 'services' => ['PROGRAM_PICKER']],
                        ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']],
                        ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']],
                    ],
                ],
            ],
            'lanes' => [
                [
                    'id' => 7,
                    'relay_slots' => [
                        ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'],
                        ['slot' => 'MACHINE', 'relay_id' => 'M-7'],
                        ['slot' => 'PROGRAM_PICKER', 'relay_id' => 'PICKER-7'],
                        ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'],
                        ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'],
                    ],
                ],
            ],
        ],
        'lookups' => [
            'labels' => [
                'lanes' => ['7' => 'Lane 7'],
                'conditions' => ['21' => 'Gate'],
                'tasks' => ['41' => 'Start machine'],
            ],
        ],
    ]);

    expect(array_column($debug['signal_timeline'], 'sequence'))->toBe(range(1, 11))
        ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe([
            'SESSION',
            'ENTRY',
            'MACHINE',
            'MACHINE',
            'PROGRAM_PICKER',
            'CLEANER',
            'CLEANER',
            'EXIT',
            'CLEANER',
            'MACHINE',
            'SESSION',
        ])
        ->and($debug['signal_timeline'][1]['signal_type'])->toBe('studio_action_relay_pulse')
        ->and($debug['signal_timeline'][1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 2])
        ->and($debug['signal_timeline'][4]['signal_type'])->toBe('studio_action_relay_switch')
        ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['action_id' => 82, 'id' => 'PICKER-7', 'on' => false])
        ->and($debug['signal_timeline'][6]['payload'])->toMatchArray(['action_id' => 83, 'id' => 'CLEAN-7', 'on' => false])
        ->and($debug['actions'][0]['state'])->toBe('active')
        ->and($debug['graph_annotations']['nodes']['action:81']['state'])->toBe('active');
});

it('simulates lane-scoped wash start actions for property gates and lane entrance ports', function (): void {
    $service = selfserve_wash_flow_without_constructor();

    $debug = $service->buildStudioDebugPayload(6, [
        'lane' => [
            'id' => 7,
            'department' => 6,
            'name' => 'Lane 7',
            'relay_in_id' => 'ENTRY-7',
            'relay_out_id' => 'EXIT-7',
            'relay_machine_id' => 'M-7',
            'relay_machine_cleaner_id' => 'CLEAN-7',
        ],
        'machine_type' => ['id' => 1001, 'name' => 'Portal'],
        'vehicle' => null,
        'reg' => 'TEST123',
        'customer_number' => 123,
        'vehicle_type_id' => 2,
        'answers' => [],
        'answer_sources' => [],
        'questions' => [],
        'tasks' => [['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']]],
        'allowed_services' => ['MACHINE'],
        'machine_available' => true,
        'all_visible_questions_answered' => true,
        'allowed' => true,
        'config_version_id' => 90,
        'config_source' => 'draft',
        'evaluation_trace' => [
            'visible_question_ids' => [],
            'visibility_condition_results' => [],
            'condition_results' => [],
            'task_gates' => [],
        ],
        'debug_candidates' => [
            'questions' => [],
            'conditions' => [],
            'rules' => [],
            'tasks' => [['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE']]],
            'actions' => [
                [
                    'id' => 80,
                    'name' => 'Open property entrance on lane start',
                    'event' => 'wash_start_command',
                    'wash_mode' => 'both',
                    'operation' => 'open_property_entrance_gate',
                    'lane' => 7,
                    'order_priority' => 1,
                ],
                [
                    'id' => 81,
                    'name' => 'Open lane entrance on lane start',
                    'event' => 'wash_start_command',
                    'wash_mode' => 'both',
                    'operation' => 'open_lane_entrance_port',
                    'lane' => 7,
                    'order_priority' => 2,
                    'options' => ['toggle_after_seconds' => 3],
                ],
            ],
        ],
    ], [
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 701,
                    'label' => 'Roskilde Edge',
                    'status' => 'ONLINE',
                    'bindings' => [
                        ['relay_id' => 'ENTRY-7', 'label' => 'Entry', 'role' => 'ENTRY', 'services' => ['ENTRY']],
                        ['relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE']],
                        ['relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER']],
                        ['relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT']],
                    ],
                ],
            ],
            'lanes' => [
                [
                    'id' => 7,
                    'relay_slots' => [
                        ['slot' => 'ENTRY', 'relay_id' => 'ENTRY-7'],
                        ['slot' => 'MACHINE', 'relay_id' => 'M-7'],
                        ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'],
                        ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'],
                    ],
                ],
            ],
        ],
    ]);

    $startSignals = array_values(array_filter(
        $debug['signal_timeline'],
        static fn(array $row): bool => str_starts_with((string)($row['signal_type'] ?? ''), 'studio_action_')
            && ($row['payload']['event'] ?? null) === 'wash_start_command'
    ));

    expect(array_column($startSignals, 'relay_role'))->toBe(['PROPERTY_ENTRANCE', 'ENTRY'])
        ->and(array_column($startSignals, 'predicted_status'))->toBe(['sent', 'sent'])
        ->and($startSignals[0]['payload'])->toMatchArray(['action_id' => 80, 'command' => 'OPEN_PROPERTY_ACCESS_GATE'])
        ->and($startSignals[1]['payload'])->toMatchArray(['action_id' => 81, 'id' => 'ENTRY-7', 'toggle_after' => 3]);
});

it('adds ordered simulator signal timeline rows for virtual hardware dry runs', function (): void {
    $service = selfserve_wash_flow_without_constructor();

    $debug = $service->buildStudioDebugPayload(6, [
        'lane' => ['id' => 7, 'name' => 'Lane 7'],
        'machine_type' => ['id' => 1001, 'name' => 'Portal'],
        'vehicle' => null,
        'reg' => 'TEST123',
        'customer_number' => null,
        'vehicle_type_id' => 2,
        'answers' => [],
        'answer_sources' => [],
        'questions' => [],
        'tasks' => [
            ['id' => 41, 'task' => 'Start machine', 'services' => ['MACHINE']],
        ],
        'allowed_services' => ['MACHINE'],
        'machine_available' => true,
        'all_visible_questions_answered' => true,
        'allowed' => true,
        'config_version_id' => 90,
        'config_source' => 'draft',
        'evaluation_trace' => [
            'visible_question_ids' => [],
            'visibility_condition_results' => [],
            'condition_results' => [],
            'task_gates' => [
                ['task_id' => 41, 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'satisfied' => true],
            ],
        ],
        'debug_candidates' => [
            'questions' => [],
            'conditions' => [],
            'rules' => [],
            'tasks' => [
                ['id' => 41, 'task' => 'Start machine', 'gate_type' => 'ALWAYS', 'gate_ref_id' => null, 'services' => ['MACHINE'], 'order_priority' => 1],
            ],
        ],
    ], [
        'gateway_workspace' => [
            'gateways' => [
                [
                    'id' => 'virtual-main',
                    'label' => 'Virtual Studio Gateway',
                    'status' => 'VIRTUAL',
                    'virtual' => true,
                    'bindings' => [
                        ['node_id' => 'binding:virtual-main:M-7:0', 'relay_id' => 'M-7', 'label' => 'Machine', 'role' => 'MACHINE', 'services' => ['MACHINE'], 'virtual' => true],
                        ['node_id' => 'binding:virtual-main:CLEAN-7:1', 'relay_id' => 'CLEAN-7', 'label' => 'Cleaner', 'role' => 'CLEANER', 'services' => ['CLEANER'], 'virtual' => true],
                        ['node_id' => 'binding:virtual-main:EXIT-7:2', 'relay_id' => 'EXIT-7', 'label' => 'Exit', 'role' => 'EXIT', 'services' => ['EXIT'], 'virtual' => true],
                    ],
                ],
            ],
            'lanes' => [
                [
                    'id' => 7,
                    'relay_slots' => [
                        ['slot' => 'MACHINE', 'relay_id' => 'M-7'],
                        ['slot' => 'CLEANER', 'relay_id' => 'CLEAN-7'],
                        ['slot' => 'EXIT', 'relay_id' => 'EXIT-7'],
                    ],
                ],
            ],
            'virtual' => ['has_virtual_hardware' => true],
        ],
        'lookups' => ['labels' => ['lanes' => ['7' => 'Lane 7'], 'tasks' => ['41' => 'Start machine']]],
    ]);

    expect(array_column($debug['signal_timeline'], 'sequence'))->toBe([1, 2, 3, 4, 5, 6, 7, 8])
        ->and(array_column($debug['signal_timeline'], 'relay_role'))->toBe(['SESSION', 'MACHINE', 'MACHINE', 'CLEANER', 'EXIT', 'CLEANER', 'MACHINE', 'SESSION'])
        ->and($debug['signal_timeline'][1]['predicted_status'])->toBe('virtual_only')
        ->and($debug['signal_timeline'][2]['signal_type'])->toBe('shelly_event')
        ->and($debug['signal_timeline'][2]['runtime_stage'])->toBe('machine_start_signal')
        ->and($debug['signal_timeline'][2]['transport'])->toBe('shelly_webhook_or_edge_gateway_event')
        ->and($debug['signal_timeline'][2]['payload'])->toMatchArray(['event' => 'input.toggle_on', 'bill_machine_wash' => true])
        ->and($debug['signal_timeline'][4]['payload'])->toMatchArray(['id' => 'EXIT-7', 'toggle_after' => 1])
        ->and($debug['hardware']['signal_timeline'][6]['payload'])->toMatchArray(['id' => 'M-7', 'on' => false]);
});
