<?php

$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/';

app_require('classes/bird.php');
app_require('objects/department_gates_o.php');
app_require('routes/birdVoiceWebhooksRoute.php');

use classes\bird;
use objects\department_gates_o;

final class BirdVoiceWebhookLifecycleBirdClientFake extends bird
{
    public array $answerPayloads = [];
    public array $gatherPayloads = [];
    public array $updatePayloads = [];
    public ?string $answerErrorMessage = null;

    public function __construct()
    {
    }

    public function answerVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload = []): array|object|null
    {
        if ($this->answerErrorMessage !== null) {
            throw new RuntimeException($this->answerErrorMessage);
        }

        $this->answerPayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload');

        return ['status' => 'accepted'];
    }

    public function gatherMessage(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->gatherPayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload');

        return ['status' => 'unexpected'];
    }

    public function updateVoiceCall(string $workspaceId, string $channelId, string $callId, array $payload): array|object|null
    {
        $this->updatePayloads[] = compact('workspaceId', 'channelId', 'callId', 'payload');

        return ['status' => 'unexpected'];
    }
}

final class BirdVoiceWebhookLifecycleGateFake extends department_gates_o
{
    public function __construct(
        int $id,
        private readonly bool $existsFlag = true,
    ) {
        $this->id = $id;
    }

    public function exists(): bool
    {
        return $this->existsFlag;
    }
}

final class BirdVoiceWebhookLifecycleRouteTestDouble extends \routes\birdVoiceWebhooksRoute
{
    public array $rawState = [];
    public array $ttls = [];
    public array $activeLocks = [];
    public bool $lockAvailable = true;
    public ?BirdVoiceWebhookLifecycleBirdClientFake $lastClient = null;
    public array $eligibleDepartments = [];
    public array $gatesByDepartmentAndType = [];
    public array $openedGates = [];
    public ?string $gateOpenErrorMessage = null;

    public function __construct()
    {
        $this->eligibleDepartments = [
            [
                'department_id' => 11,
                'department_name' => 'Nord',
                'order_priority' => 1,
                'has_entrance_gate' => true,
                'has_exit_gate' => true,
            ],
            [
                'department_id' => 22,
                'department_name' => 'Syd',
                'order_priority' => 2,
                'has_entrance_gate' => true,
                'has_exit_gate' => false,
            ],
        ];

        $this->gatesByDepartmentAndType = [
            '11:entrance' => new BirdVoiceWebhookLifecycleGateFake(1101),
            '11:exit' => new BirdVoiceWebhookLifecycleGateFake(1102),
            '22:entrance' => new BirdVoiceWebhookLifecycleGateFake(2201),
        ];
    }

    public function runLifecycle(array $payload, string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array
    {
        $client = $this->lastClient ?? new BirdVoiceWebhookLifecycleBirdClientFake();
        $this->lastClient = $client;

        return $this->handleInboundCallLifecycle($client, $payload, $callId, $workspaceId, $channelId);
    }

    public function loadState(string $callId): ?array
    {
        return $this->readIvrState($callId);
    }

    protected function loadEligibleDepartmentSummaries(): array
    {
        return $this->eligibleDepartments;
    }

    protected function resolvePhoneCallGate(int $departmentId, string $gateType): ?department_gates_o
    {
        return $this->gatesByDepartmentAndType[$departmentId . ':' . $gateType] ?? null;
    }

    protected function resolvePhoneCallGateById(int $gateId): ?department_gates_o
    {
        foreach ($this->gatesByDepartmentAndType as $gate) {
            if ((int)$gate->id === $gateId) {
                return $gate;
            }
        }

        return null;
    }

    protected function shouldAcceptInboundCall(bird $client): bool
    {
        return true;
    }

    protected function triggerGateOpen(department_gates_o $gate): void
    {
        if ($this->gateOpenErrorMessage !== null) {
            throw new RuntimeException($this->gateOpenErrorMessage);
        }

        $this->openedGates[] = (int)$gate->id;
    }

    protected function writeIvrStateRaw(string $key, string $value, int $ttlSeconds): void
    {
        $this->rawState[$key] = $value;
        $this->ttls[$key] = $ttlSeconds;
    }

    protected function readIvrStateRaw(string $key): ?string
    {
        return $this->rawState[$key] ?? null;
    }

    protected function deleteIvrStateRaw(string $key): void
    {
        unset($this->rawState[$key], $this->ttls[$key]);
    }

    protected function acquireIvrLockRaw(string $key, int $ttlSeconds): bool
    {
        if (!$this->lockAvailable || isset($this->activeLocks[$key])) {
            return false;
        }

        $this->activeLocks[$key] = ['ttl' => $ttlSeconds];

        return true;
    }

    protected function releaseIvrLockRaw(string $key): void
    {
        unset($this->activeLocks[$key]);
    }
}

function bird_webhook_initial_payload(string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array
{
    return [
        'payload' => [
            'endKey' => '#',
            'input' => 'speech',
            'maxNumKeys' => 9,
            'retries' => 3,
            'speechLocale' => 'en-US',
            'timeout' => 30,
            'say' => [
                'locale' => 'en-US',
                'voice' => 'female',
            ],
        ],
        'request' => [
            'callId' => $callId,
            'channelId' => $channelId,
            'workspaceId' => $workspaceId,
        ],
        'requestId' => 'request-123',
        'waitConditions' => [
            'events' => [
                [
                    'action' => 'continue',
                    'name' => 'call_command_gather_finished',
                ],
            ],
            'timeout' => 'PT10M',
        ],
    ];
}

function bird_webhook_resumed_payload(?string $eventKeys, ?string $fallbackKeys = null, string $callId = 'call_1', string $channelId = 'channel_1'): array
{
    $payload = [
        'event' => [
            'callCommand' => [
                'callId' => $callId,
                'channelId' => $channelId,
                'gather' => [],
            ],
        ],
        'resumeData' => [
            'action' => 'continue',
        ],
    ];

    if ($eventKeys !== null) {
        $payload['event']['callCommand']['gather']['keys'] = $eventKeys;
    }

    if ($fallbackKeys !== null) {
        $payload['result'] = ['keys' => $fallbackKeys];
    }

    return $payload;
}

function bird_webhook_legacy_initial_payload(string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array
{
    return [
        'callId' => $callId,
        'channelId' => $channelId,
        'workspaceId' => $workspaceId,
    ];
}

function bird_webhook_flow_selection_payload(string $keys, string $callId = 'call_1', string $workspaceId = 'workspace_1', string $channelId = 'channel_1'): array
{
    return [
        'callId' => $callId,
        'channelId' => $channelId,
        'workspaceId' => $workspaceId,
        'keys' => $keys,
    ];
}

it('returns a raw 202 gather response for initial department selection', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();

    $result = $route->runLifecycle(bird_webhook_initial_payload());

    expect($result['statusCode'])->toBe(202);
    expect($result)->not->toHaveKey('success');
    expect($result['result']['status'] ?? null)->toBe('accepted');
    expect($result['event']['callCommand']['type'] ?? null)->toBe('gather');
    expect($result['event']['callCommand']['gather']['input'] ?? null)->toBe('dtmf');
    expect($result['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(1);
    expect($result['event']['callCommand']['gather']['retries'] ?? null)->toBe(3);
    expect($result['event']['callCommand']['gather']['timeout'] ?? null)->toBe(30);
    expect($result['event']['callCommand']['gather']['say']['locale'] ?? null)->toBe('en-US');
    expect($result['event']['callCommand']['gather']['say']['voice'] ?? null)->toBe('female');
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
    expect($route->lastClient?->answerPayloads ?? [])->toBe([
        [
            'workspaceId' => 'workspace_1',
            'channelId' => 'channel_1',
            'callId' => 'call_1',
            'payload' => [],
        ],
    ]);
    expect($route->lastClient?->gatherPayloads ?? [])->toBe([]);
    expect($route->lastClient?->updatePayloads ?? [])->toBe([]);

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['stage'] ?? null)->toBe('department_select');
    expect($state['request_id'] ?? null)->toBe('request-123');
});

it('accepts legacy initial webhook payloads with top-level call identifiers', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();

    $result = $route->runLifecycle(bird_webhook_legacy_initial_payload());

    expect($result['statusCode'])->toBe(200);
    expect($result['status'])->toBe('gather');
    expect($result['completed'])->toBeFalse();
    expect($result['stage'])->toBe('department_select');
    expect($result['gather']['input'] ?? null)->toBe('dtmf');
    expect($result['gather']['maxNumKeys'] ?? null)->toBe(1);
    expect($result['gather']['retries'] ?? null)->toBe(3);
    expect($result['gather']['timeout'] ?? null)->toBe(30);
    expect($result['gather']['endKey'] ?? null)->toBe('#');
    expect($result['gatherInput'] ?? null)->toBe('dtmf');
    expect($result['gatherMaxNumKeys'] ?? null)->toBe(1);
    expect($result['gatherRetries'] ?? null)->toBe(3);
    expect($result['gatherTimeout'] ?? null)->toBe(30);
    expect($result['gatherEndKey'] ?? null)->toBe('#');
    expect($result['gatherSayLocale'] ?? null)->toBe('en-US');
    expect($result['gatherSayVoice'] ?? null)->toBe('female');
    expect($result['gather']['say']['locale'] ?? null)->toBe('en-US');
    expect($result['gather']['say']['voice'] ?? null)->toBe('female');
    expect($result['gather']['say']['text'] ?? null)->toContain('Choose department.');
    expect($result['prompt'] ?? null)->toContain('Choose department.');
    expect($route->lastClient?->answerPayloads ?? [])->toBe([
        [
            'workspaceId' => 'workspace_1',
            'channelId' => 'channel_1',
            'callId' => 'call_1',
            'payload' => [],
        ],
    ]);

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['stage'] ?? null)->toBe('department_select');
    expect($state['resume_action'] ?? null)->toBe('continue');
    expect($state['wait_timeout'] ?? null)->toBe('PT10M');
});

it('returns a flow-data gather payload after department selection in native flow mode when distinct gate choices exist', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();

    $route->runLifecycle(bird_webhook_legacy_initial_payload());
    $result = $route->runLifecycle(bird_webhook_flow_selection_payload('1'));

    expect($result['statusCode'])->toBe(200);
    expect($result['status'])->toBe('gather');
    expect($result['completed'])->toBeFalse();
    expect($result['stage'])->toBe('gate_type_select');
    expect($result['prompt'] ?? null)->toContain('You selected Nord.');
    expect($result['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.');
    expect($result['selection']['departmentId'] ?? null)->toBe(11);
    expect($result['selection']['departmentName'] ?? null)->toBe('Nord');

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['stage'] ?? null)->toBe('gate_type_select');
    expect($state['selected_department_id'] ?? null)->toBe(11);
});

it('opens the gate immediately after department selection when entrance and exit resolve to the same gate in native flow mode', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();
    $route->eligibleDepartments = [
        [
            'department_id' => 44,
            'department_name' => 'Shared',
            'order_priority' => 1,
            'has_entrance_gate' => true,
            'has_exit_gate' => true,
        ],
    ];
    $sharedGate = new BirdVoiceWebhookLifecycleGateFake(4401);
    $route->gatesByDepartmentAndType = [
        '44:entrance' => $sharedGate,
        '44:exit' => $sharedGate,
    ];

    $route->runLifecycle(bird_webhook_legacy_initial_payload());
    $result = $route->runLifecycle(bird_webhook_flow_selection_payload('1'));

    expect($result['statusCode'])->toBe(200);
    expect($result['status'] ?? null)->toBe('completed');
    expect($result['completed'] ?? null)->toBeTrue();
    expect($result['action'] ?? null)->toBe('gate_opened');
    expect($result['gateOpened'] ?? null)->toBeTrue();
    expect($result['departmentId'] ?? null)->toBe(44);
    expect($result['departmentName'] ?? null)->toBe('Shared');
    expect($result['gateType'] ?? null)->toBeNull();
    expect($result['gateId'] ?? null)->toBe(4401);
    expect($route->openedGates)->toBe([4401]);
    expect($route->loadState('call_1'))->toBeNull();
});

it('returns a flow-data completion payload after gate confirmation in native flow mode', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();

    $route->runLifecycle(bird_webhook_legacy_initial_payload());
    $route->runLifecycle(bird_webhook_flow_selection_payload('1'));
    $result = $route->runLifecycle(bird_webhook_flow_selection_payload('2'));

    expect($result['statusCode'])->toBe(200);
    expect($result['status'] ?? null)->toBe('completed');
    expect($result['completed'] ?? null)->toBeTrue();
    expect($result['action'] ?? null)->toBe('gate_opened');
    expect($result['gateOpened'] ?? null)->toBeTrue();
    expect($result['departmentId'] ?? null)->toBe(11);
    expect($result['departmentName'] ?? null)->toBe('Nord');
    expect($result['gateType'] ?? null)->toBe('exit');
    expect($result['gateId'] ?? null)->toBe(1102);
    expect($route->openedGates)->toBe([1102]);
    expect($route->loadState('call_1'))->toBeNull();
});

it('returns a second-stage raw 202 gather response after department selection when distinct gate choices exist', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();

    $route->runLifecycle(bird_webhook_initial_payload());
    $result = $route->runLifecycle(bird_webhook_resumed_payload('1'));

    expect($result['statusCode'])->toBe(202);
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Nord.');
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance. Press 2 for exit.');

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['stage'] ?? null)->toBe('gate_type_select');
    expect($state['selected_department_id'] ?? null)->toBe(11);
    expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance', '2' => 'exit']);
    expect($route->lastClient?->answerPayloads ?? [])->toHaveCount(1);
    expect($route->openedGates)->toBe([]);
});

it('opens the shared gate immediately after department selection in raw command mode', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();
    $route->eligibleDepartments = [
        [
            'department_id' => 44,
            'department_name' => 'Shared',
            'order_priority' => 1,
            'has_entrance_gate' => true,
            'has_exit_gate' => true,
        ],
    ];
    $sharedGate = new BirdVoiceWebhookLifecycleGateFake(4401);
    $route->gatesByDepartmentAndType = [
        '44:entrance' => $sharedGate,
        '44:exit' => $sharedGate,
    ];

    $route->runLifecycle(bird_webhook_initial_payload());
    $result = $route->runLifecycle(bird_webhook_resumed_payload('1'));

    expect($result['statusCode'])->toBe(200);
    expect($result['result']['action'] ?? null)->toBe('gate_opened');
    expect($result['result']['gateOpened'] ?? null)->toBeTrue();
    expect($result['result']['departmentId'] ?? null)->toBe(44);
    expect($result['result']['gateType'] ?? null)->toBeNull();
    expect($result['result']['gateId'] ?? null)->toBe(4401);
    expect($route->openedGates)->toBe([4401]);
    expect($route->loadState('call_1'))->toBeNull();
});

it('opens the selected gate and clears redis state on final selection', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();

    $route->runLifecycle(bird_webhook_initial_payload());
    $route->runLifecycle(bird_webhook_resumed_payload('1'));
    $result = $route->runLifecycle(bird_webhook_resumed_payload('2'));

    expect($result['statusCode'])->toBe(200);
    expect($result['statusText'])->toBe('OK');
    expect($result['result']['action'] ?? null)->toBe('gate_opened');
    expect($result['result']['gateOpened'] ?? null)->toBeTrue();
    expect($result['result']['departmentId'] ?? null)->toBe(11);
    expect($result['result']['gateType'] ?? null)->toBe('exit');
    expect($result['result']['gateId'] ?? null)->toBe(1102);
    expect($route->openedGates)->toBe([1102]);
    expect($route->loadState('call_1'))->toBeNull();
});

it('reprompts with invalid selection while keeping webhook state', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();

    $route->runLifecycle(bird_webhook_initial_payload());
    $result = $route->runLifecycle(bird_webhook_resumed_payload('9'));

    expect($result['statusCode'])->toBe(202);
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Invalid selection.');
    expect($route->openedGates)->toBe([]);

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['stage'] ?? null)->toBe('department_select');
    expect($state['invalid_selection_count'] ?? 0)->toBe(1);
});

it('uses fallback dtmf extraction when event gather keys are missing', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();

    $route->runLifecycle(bird_webhook_initial_payload());
    $result = $route->runLifecycle(bird_webhook_resumed_payload(null, '2'));

    expect($result['statusCode'])->toBe(202);
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Syd.');
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.');
    expect($route->openedGates)->toBe([]);

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['selected_department_id'] ?? null)->toBe(22);
    expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance']);
});

it('prompts for department selection even when only one eligible department exists', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();
    $route->eligibleDepartments = [
        [
            'department_id' => 44,
            'department_name' => 'Solo',
            'order_priority' => 1,
            'has_entrance_gate' => true,
            'has_exit_gate' => false,
        ],
    ];
    $route->gatesByDepartmentAndType = [
        '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
    ];

    $result = $route->runLifecycle(bird_webhook_initial_payload());

    expect($result['statusCode'])->toBe(202);
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for Solo.');
    expect($route->openedGates)->toBe([]);

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['stage'] ?? null)->toBe('department_select');
});

it('prompts for a single available gate type and opens only after explicit confirmation', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();
    $route->eligibleDepartments = [
        [
            'department_id' => 44,
            'department_name' => 'Solo',
            'order_priority' => 1,
            'has_entrance_gate' => true,
            'has_exit_gate' => false,
        ],
    ];
    $route->gatesByDepartmentAndType = [
        '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
    ];

    $route->runLifecycle(bird_webhook_initial_payload());
    $gatePrompt = $route->runLifecycle(bird_webhook_resumed_payload('1'));

    expect($gatePrompt['statusCode'])->toBe(202);
    expect($gatePrompt['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Solo.');
    expect($gatePrompt['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.');
    expect($route->openedGates)->toBe([]);

    $result = $route->runLifecycle(bird_webhook_resumed_payload('1'));

    expect($result['statusCode'])->toBe(200);
    expect($result['result']['action'] ?? null)->toBe('gate_opened');
    expect($result['result']['gateId'] ?? null)->toBe(4401);
    expect($route->openedGates)->toBe([4401]);
    expect($route->loadState('call_1'))->toBeNull();
});

it('supports multi-digit department selections before gate confirmation', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();
    $route->eligibleDepartments = [];
    $route->gatesByDepartmentAndType = [];

    for ($index = 1; $index <= 10; $index++) {
        $departmentId = 100 + $index;
        $route->eligibleDepartments[] = [
            'department_id' => $departmentId,
            'department_name' => 'Department ' . $index,
            'order_priority' => $index,
            'has_entrance_gate' => true,
            'has_exit_gate' => false,
        ];
        $route->gatesByDepartmentAndType[$departmentId . ':entrance'] = new BirdVoiceWebhookLifecycleGateFake(1000 + $index);
    }

    $initial = $route->runLifecycle(bird_webhook_initial_payload());

    expect($initial['statusCode'])->toBe(202);
    expect($initial['event']['callCommand']['gather']['maxNumKeys'] ?? null)->toBe(2);
    expect($initial['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Enter the option number followed by pound.');
    expect($initial['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 10 for Department 10.');

    $selected = $route->runLifecycle(bird_webhook_resumed_payload('10#'));

    expect($selected['statusCode'])->toBe(202);
    expect($selected['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('You selected Department 10.');
    expect($selected['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Press 1 for entrance.');

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['selected_department_id'] ?? null)->toBe(110);
    expect($state['gate_options'] ?? null)->toBe(['1' => 'entrance']);
});

it('returns a direct raw 200 completion when no departments are eligible', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();
    $route->eligibleDepartments = [];

    $result = $route->runLifecycle(bird_webhook_initial_payload());

    expect($result['statusCode'])->toBe(200);
    expect($result['result']['action'] ?? null)->toBe('no_action');
    expect($result['result']['message'] ?? null)->toContain('No phone-controlled gates are configured');
    expect($route->lastClient?->answerPayloads ?? [])->toHaveCount(1);
    expect($route->openedGates)->toBe([]);
});

it('continues with the first gather prompt when backend call acceptance fails', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();
    $route->lastClient = new BirdVoiceWebhookLifecycleBirdClientFake();
    $route->lastClient->answerErrorMessage = 'already answered';

    $result = $route->runLifecycle(bird_webhook_initial_payload());

    expect($result['statusCode'])->toBe(202);
    expect($result['event']['callCommand']['gather']['say']['text'] ?? null)->toContain('Choose department.');
    expect($route->lastClient?->answerPayloads ?? [])->toBe([]);

    $state = $route->loadState('call_1');
    expect($state)->not->toBeNull();
    expect($state['stage'] ?? null)->toBe('department_select');
});

it('returns a business-failure raw 200 response when gate opening fails', function (): void {
    $route = new BirdVoiceWebhookLifecycleRouteTestDouble();
    $route->eligibleDepartments = [
        [
            'department_id' => 44,
            'department_name' => 'Solo',
            'order_priority' => 1,
            'has_entrance_gate' => true,
            'has_exit_gate' => false,
        ],
    ];
    $route->gatesByDepartmentAndType = [
        '44:entrance' => new BirdVoiceWebhookLifecycleGateFake(4401),
    ];
    $route->gateOpenErrorMessage = 'relay offline';

    $route->runLifecycle(bird_webhook_initial_payload());
    $route->runLifecycle(bird_webhook_resumed_payload('1'));
    $result = $route->runLifecycle(bird_webhook_resumed_payload('1'));

    expect($result['statusCode'])->toBe(200);
    expect($result['result']['status'] ?? null)->toBe('failed');
    expect($result['result']['action'] ?? null)->toBe('gate_open_failed');
    expect($result['result']['gateOpened'] ?? null)->toBeFalse();
    expect($result['result']['message'] ?? null)->toContain('relay offline');
    expect($route->loadState('call_1'))->toBeNull();
});
