<?php

namespace classes;

use Exception;
use objects\department_relays_o;
use objects\departments_o;
use objects\department_variables_o;
use objects\edge_gateway_audit_logs_o;
use objects\edge_gateway_claim_tokens_o;
use objects\edge_gateway_command_jobs_o;
use objects\edge_gateway_device_inventory_o;
use objects\edge_gateway_log_entries_o;
use objects\edge_gateway_relay_bindings_o;
use objects\edge_gateway_shell_sessions_o;
use objects\edge_gateways_o;

class edge_gateway_manager
{
    public const DEPARTMENT_VARIABLE_TRANSPORT_MODE = 'shelly_transport_mode';
    public const TRANSPORT_MODE_CLOUD = 'cloud';
    public const TRANSPORT_MODE_GATEWAY = 'gateway';
    public const DELIVERY_CHANNEL_BROKER = 'BROKER_FAST_PATH';
    public const DELIVERY_CHANNEL_API = 'API_POLLING';
    public const DELIVERY_CHANNEL_CLOUD = 'CLOUD';
    public const COMMAND_BATCH_RELAY_STATUS = 'BATCH_RELAY_STATUS';
    public const COMMAND_BATCH_SET_RELAY_STATE = 'BATCH_SET_RELAY_STATE';
    public const RELAY_FALLBACK_PREFER_LOCAL = 'PREFER_LOCAL';
    public const RELAY_FALLBACK_LOCAL_ONLY = 'LOCAL_ONLY';
    public const RELAY_FALLBACK_CLOUD_ONLY = 'CLOUD_ONLY';
    public const STATUS_PENDING = 'PENDING';
    public const STATUS_ONLINE = 'ONLINE';
    public const STATUS_DEGRADED = 'DEGRADED';
    public const STATUS_OFFLINE = 'OFFLINE';
    public const DEFAULT_RELEASE_CHANNEL = 'stable';
    public const DEFAULT_INSTALL_VERSION = 'compose-php-agent-v3';
    public const DEFAULT_AGENT_SERVICE_NAME = 'truckwash-edge-agent.service';
    public const DEFAULT_STACK_SERVICE_NAME = 'truckwash-edge-gateway-stack.service';
    public const DEFAULT_COMPOSE_STACK_FILE = 'docker-compose.gateway.yml';
    public const DEFAULT_LAUNCHER_SCRIPT_NAME = 'gateway-launcher.sh';
    public const DEFAULT_LAN_WORKER_ARTIFACT = 'lan-worker.php';
    public const DEFAULT_AUTO_UPDATER_ARTIFACT = 'auto-updater.php';
    public const DEFAULT_EDGE_AGENT_DOCKERFILE = 'Dockerfile.edge-agent';
    public const DEFAULT_LAN_WORKER_DOCKERFILE = 'Dockerfile.lan-worker';
    public const DEFAULT_AUTO_UPDATER_DOCKERFILE = 'Dockerfile.auto-updater';
    public const DEFAULT_INSTALL_DIR = '/opt/truckwash-edge-agent';
    public const DEFAULT_RUNTIME_DIR = '/opt/truckwash-edge-agent/runtime';
    public const DEFAULT_STATE_DATABASE_PATH = '/opt/truckwash-edge-agent/runtime/gateway-state.sqlite';
    public const DEFAULT_UPDATE_WINDOW = '02:00-04:00';
    public const DEFAULT_COMPOSE_PROJECT_NAME = 'truckwash-edge-gateway';
    public const DEFAULT_EDGE_AGENT_BASE_IMAGE = 'php:8.2-cli-bookworm';
    public const DEFAULT_LAN_WORKER_BASE_IMAGE = 'php:8.2-cli-bookworm';
    public const DEFAULT_AUTO_UPDATER_BASE_IMAGE = 'php:8.2-cli-bookworm';
    public const DEFAULT_REDIS_BASE_IMAGE = 'redis:7-alpine';
    public const DEFAULT_MARIADB_BASE_IMAGE = 'mariadb:11';
    public const DEFAULT_MINIO_BASE_IMAGE = 'minio/minio:latest';
    public const DEFAULT_WORKER_BASE_URL = 'http://lan-worker:8090';
    public const INSTALL_TOKEN_TTL_SECONDS = 1800;
    public const INSTALL_SESSION_STATUS_PENDING = 'PENDING';
    public const INSTALL_SESSION_STATUS_RUNNING = 'RUNNING';
    public const INSTALL_SESSION_STATUS_CLAIMED = 'CLAIMED';
    public const INSTALL_SESSION_STATUS_FAILED = 'FAILED';
    public const INSTALL_SESSION_STATUS_EXPIRED = 'EXPIRED';
    private const INSTALL_SESSION_EVENT_LIMIT = 12;
    private const INSTALL_SESSION_DIAGNOSTIC_LIMIT = 6;
    private const INSTALL_SESSION_OUTPUT_LIMIT = 2000;
    public const HEARTBEAT_DEGRADED_AFTER_SECONDS = 60;
    public const HEARTBEAT_OFFLINE_AFTER_SECONDS = 300;
    public const COMMAND_WAIT_TIMEOUT_SECONDS = 10;
    public const COMMAND_POLL_TIMEOUT_SECONDS = 20;
    public const COMMAND_POLL_INTERVAL_MICROSECONDS = 250000;
    public const COMMAND_DISPATCH_STALE_AFTER_SECONDS = 30;
    public const COMMAND_EXPIRES_AFTER_SECONDS = 90;
    public const RELAY_BATCH_DEDUPE_SECONDS = 30;
    public const RELAY_STATUS_RATE_LIMIT_PER_MINUTE = 60;
    public const RELAY_SET_RATE_LIMIT_PER_MINUTE = 30;
    public const RELAY_GATE_RATE_LIMIT_PER_MINUTE = 4;
    public const RELAY_OPERATIONAL_WINDOW_SECONDS = 900;
    public const EXPECTED_RELAY_ROLE_ENTRANCE_GATE = 'ENTRANCE_GATE';
    public const EXPECTED_RELAY_ROLE_EXIT_GATE = 'EXIT_GATE';
    public const EXPECTED_RELAY_ROLE_MACHINE = 'MACHINE';
    public const EXPECTED_RELAY_ROLE_PROGRAM_PICKER = 'PROGRAM_PICKER';
    public const EXPECTED_RELAY_ROLE_CLEANER = 'CLEANER';
    public const BROKER_PRESENCE_TTL_SECONDS = 90;
    public const BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS = 45;
    public const BROKER_HTTP_TIMEOUT_SECONDS = 5;
    public const BROWSER_STREAM_TOKEN_TTL_SECONDS = 300;
    public const SHELL_SESSION_TTL_SECONDS = 900;
    public const DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS = 180;
    public const CREDENTIAL_FRESH_AFTER_SECONDS = 2592000;
    private const EXPECTED_RELAY_ROLE_COLUMNS = [
        self::EXPECTED_RELAY_ROLE_ENTRANCE_GATE => 'relay_in_id',
        self::EXPECTED_RELAY_ROLE_EXIT_GATE => 'relay_out_id',
        self::EXPECTED_RELAY_ROLE_MACHINE => 'relay_machine_id',
        self::EXPECTED_RELAY_ROLE_PROGRAM_PICKER => 'relay_machine_program_picker_id',
        self::EXPECTED_RELAY_ROLE_CLEANER => 'relay_machine_cleaner_id',
    ];
    private const EXPECTED_RELAY_ROLE_ALIASES = [
        'ENTRANCE' => self::EXPECTED_RELAY_ROLE_ENTRANCE_GATE,
        'ENTRY' => self::EXPECTED_RELAY_ROLE_ENTRANCE_GATE,
        'IN' => self::EXPECTED_RELAY_ROLE_ENTRANCE_GATE,
        'ENTRANCE_GATE' => self::EXPECTED_RELAY_ROLE_ENTRANCE_GATE,
        'EXIT' => self::EXPECTED_RELAY_ROLE_EXIT_GATE,
        'OUT' => self::EXPECTED_RELAY_ROLE_EXIT_GATE,
        'EXIT_GATE' => self::EXPECTED_RELAY_ROLE_EXIT_GATE,
        'MACHINE' => self::EXPECTED_RELAY_ROLE_MACHINE,
        'PROGRAM_PICKER' => self::EXPECTED_RELAY_ROLE_PROGRAM_PICKER,
        'MACHINE_PROGRAM_PICKER' => self::EXPECTED_RELAY_ROLE_PROGRAM_PICKER,
        'CLEANER' => self::EXPECTED_RELAY_ROLE_CLEANER,
        'MACHINE_CLEANER' => self::EXPECTED_RELAY_ROLE_CLEANER,
    ];
    /** @var array<int,array<string,mixed>>|null */
    private ?array $shellyRelayOptionsCache = null;
    /** @var array<int,array<string,mixed>> */
    private static array $relayActionContextStack = [];

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

    public static function withRelayActionContext(array $context, callable $callback): mixed
    {
        self::$relayActionContextStack[] = $context;
        try {
            return $callback();
        } finally {
            array_pop(self::$relayActionContextStack);
        }
    }

    private static function configuredDefaultReleaseChannel(): string
    {
        try {
            $value = trim((string)(new edgegateway())->defaultReleaseChannel());
            if ($value !== '') {
                return $value;
            }
        } catch (Exception $exception) {
        }

        return self::DEFAULT_RELEASE_CHANNEL;
    }

    private static function configuredDefaultUpdateWindow(): string
    {
        try {
            $value = trim((string)(new edgegateway())->defaultUpdateWindow());
            if ($value !== '') {
                return $value;
            }
        } catch (Exception $exception) {
        }

        return self::DEFAULT_UPDATE_WINDOW;
    }

    public function createInstallToken(int $departmentId, ?string $label, ?int $createdBy = null): array
    {
        $this->requireDepartment($departmentId);

        $token = bin2hex(random_bytes(24));
        $expiresAt = $this->formatDateTime(time() + self::INSTALL_TOKEN_TTL_SECONDS);
        $claimToken = new edge_gateway_claim_tokens_o();
        $claimTokenId = $claimToken->add_object([
            'department_id' => $departmentId,
            'label' => $label,
            'token_hash' => $this->hashToken($token),
            'created_by' => $createdBy,
            'expires_at' => $expiresAt,
            'metadata_json' => [
                'install_session' => self::mergeInstallSessionUpdate([], [
                    'status' => self::INSTALL_SESSION_STATUS_PENDING,
                    'step' => self::INSTALL_SESSION_STATUS_PENDING,
                    'message' => 'Installer command generated. Run it on the gateway host.',
                ], (self::parseApplicationDateTime($expiresAt) ?? time()) - self::INSTALL_TOKEN_TTL_SECONDS),
            ],
        ]);

        $claimToken->select($claimTokenId);
        $this->writeAudit(
            null,
            $departmentId,
            'INSTALL_TOKEN_CREATED',
            $createdBy,
            ['claim_token_id' => $claimTokenId, 'label' => $label]
        );

        return [
            'claim_token_id' => $claimTokenId,
            'token' => $token,
            'expires_at' => (string)$claimToken->expires_at->value(),
            'install_command' => $this->buildInstallCommand($token),
            'install_url' => $this->buildInstallScriptUrl($token),
        ];
    }

    private function buildShellReadinessDiagnostics(edge_gateways_o $gateway): array
    {
        $gatewayId = (int)$gateway->id;
        $brokerUrl = $this->buildBrokerPublicUrl();
        $wsUrl = $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell');
        $presence = $this->readBrokerPresence($gatewayId);
        $lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null;
        $ageSeconds = self::heartbeatAgeSeconds($lastSeenAt);
        $connected = self::isBrokerPresenceConnected($presence);
        $configuredBrokerUrl = $this->configuredPublicBrokerUrl();
        $derivedWarning = null;

        if ($configuredBrokerUrl === '') {
            $apiBaseUrl = rtrim($this->getApiBaseUrl(), '/');
            $apiPath = (string)(parse_url($apiBaseUrl, PHP_URL_PATH) ?: '');
            $apiHost = (string)(parse_url($apiBaseUrl, PHP_URL_HOST) ?: '');
            if ($apiPath === '/api' && !in_array($apiHost, ['localhost', '127.0.0.1'], true)) {
                $derivedWarning = 'BROKER_PUBLIC_URL_DERIVED_WITH_API_PREFIX';
            }
        }

        $diagnostics = [
            'ready' => false,
            'reason_code' => 'BROKER_NOT_READY',
            'message' => 'Gateway shell is not ready.',
            'gateway_id' => $gatewayId,
            'broker_url' => $brokerUrl,
            'ws_url' => $wsUrl,
            'public_broker_url_configured' => $configuredBrokerUrl !== '',
            'broker_auth_mode' => $this->configuredBrokerAuthMode(),
            'derived_warning' => $derivedWarning,
            'broker_presence' => [
                'connected' => !empty($presence['connected']),
                'connection_id' => isset($presence['connection_id']) ? (string)$presence['connection_id'] : null,
                'last_seen_at' => $lastSeenAt,
                'age_seconds' => $ageSeconds,
                'disconnect_reason' => isset($presence['disconnect_reason']) ? (string)$presence['disconnect_reason'] : null,
                'last_error' => isset($presence['last_error']) ? (string)$presence['last_error'] : null,
            ],
        ];

        if ($brokerUrl === null || $wsUrl === null) {
            return array_merge($diagnostics, [
                'reason_code' => 'BROKER_NOT_CONFIGURED',
                'message' => 'The public edge broker URL is not configured, so a gateway shell cannot be opened.',
            ]);
        }

        if ($presence === []) {
            return array_merge($diagnostics, [
                'reason_code' => 'BROKER_DISCONNECTED',
                'message' => 'Gateway agent has not reported an active broker connection yet.',
            ]);
        }

        if (empty($presence['connected'])) {
            return array_merge($diagnostics, [
                'reason_code' => 'BROKER_DISCONNECTED',
                'message' => 'Gateway agent is not connected to the edge broker.',
            ]);
        }

        if (!$connected) {
            return array_merge($diagnostics, [
                'reason_code' => 'BROKER_STALE',
                'message' => 'Gateway broker presence is stale. Wait for the agent to reconnect before opening a shell.',
            ]);
        }

        return array_merge($diagnostics, [
            'ready' => true,
            'reason_code' => $derivedWarning ?: 'READY',
            'message' => $derivedWarning === null
                ? 'Gateway shell broker path is ready.'
                : 'Gateway shell broker path is ready, but the broker URL was derived from the API URL.',
        ]);
    }

    /**
     * @throws Exception
     */
    public function claimGateway(string $token, string $hostname, ?string $installedVersion = null, array $metadata = []): array
    {
        $claimToken = $this->requireClaimToken($token);
        if ($claimToken->used_at->value() !== null) {
            throw new Exception('Install token has already been used');
        }

        $departmentId = (int)$claimToken->department_id->value();
        $label = trim((string)($claimToken->label->value() ?? $hostname));
        $label = $label !== '' ? $label : 'Department gateway';
        $agentToken = bin2hex(random_bytes(32));

        $gateway = new edge_gateways_o();
        $gatewayId = $gateway->add_object([
            'department_id' => $departmentId,
            'label' => $label,
            'hostname' => trim($hostname) !== '' ? trim($hostname) : null,
            'agent_token_hash' => $this->hashToken($agentToken),
            'status' => self::STATUS_ONLINE,
            'transport_mode' => self::TRANSPORT_MODE_GATEWAY,
            'release_channel' => self::configuredDefaultReleaseChannel(),
            'installed_version' => $installedVersion,
            'target_version' => $installedVersion,
            'last_heartbeat_at' => $this->now(),
            'last_seen_ip' => $this->remoteIp(),
            'discovery_status' => 'PENDING',
            'is_primary' => 1,
            'metadata_json' => array_merge($metadata, [
                'credentials_rotated_at' => $this->now(),
                'agent_runtime' => 'compose-php',
                'runtime_mode' => 'compose',
                'update_window' => self::configuredDefaultUpdateWindow(),
                'container_health' => [
                    'overall_status' => self::STATUS_PENDING,
                    'services' => self::defaultGatewayServiceHealth(self::STATUS_PENDING),
                ],
                'outbox_status' => [
                    'depth' => 0,
                    'oldest_age_seconds' => 0,
                    'last_flushed_at' => null,
                    'pending_types' => [],
                ],
                'rollback_status' => [
                    'state' => 'NONE',
                    'reason' => null,
                    'at' => null,
                ],
                'last_sync_at' => null,
            ]),
        ]);

        $claimToken->used_at->set($this->now());
        $this->persistInstallSession($claimToken, [
            'status' => self::INSTALL_SESSION_STATUS_CLAIMED,
            'step' => self::INSTALL_SESSION_STATUS_CLAIMED,
            'message' => 'Gateway claimed successfully.',
            'gateway_id' => $gatewayId,
            'last_error' => null,
            'diagnostics' => [],
        ]);
        $gateway->select($gatewayId);
        $this->setGatewayPrimaryState($gateway, true);

        $this->writeAudit(
            $gatewayId,
            $departmentId,
            'GATEWAY_CLAIMED',
            null,
            ['hostname' => $hostname, 'installed_version' => $installedVersion]
        );

        $gatewayPayload = $this->getGateway($gatewayId);
        edge_gateway_view_cache::syncGateway($gatewayPayload);

        return [
            'gateway' => $gatewayPayload,
            'agent_token' => $agentToken,
            'heartbeat_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/heartbeat',
            'commands_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/commands/poll',
            'operations_poll_url' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/next',
            'operation_events_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/events',
            'operation_complete_url_template' => $this->getApiBaseUrl() . '/edge-agent/gateways/' . $gatewayId . '/operations/{operationId}/complete',
            'broker_url' => $this->buildBrokerPublicUrl(),
            'release_channel' => (string)$gateway->release_channel->value(),
        ];
    }

    /**
     * @throws Exception
     */
    public function authenticateGateway(int $gatewayId, string $plainToken): edge_gateways_o
    {
        $gateway = $this->requireGateway($gatewayId);
        if (!hash_equals((string)$gateway->agent_token_hash->value(), $this->hashToken($plainToken))) {
            throw new Exception('Invalid edge gateway token');
        }

        return $gateway;
    }

    /**
     * @throws Exception
     */
    public function recordHeartbeat(int $gatewayId, string $plainToken, array $payload): array
    {
        $gateway = $this->authenticateGateway($gatewayId, $plainToken);
        $existingMetadata = (array)($gateway->metadata_json->value() ?? []);
        $payloadMetadata = (array)($payload['metadata'] ?? []);
        $metadata = $this->mergeHeartbeatBrokerPresence($gatewayId, $existingMetadata, $payloadMetadata);
        $gateway->status->set((string)($payload['status'] ?? self::STATUS_ONLINE));
        $gateway->hostname->set($payload['hostname'] ?? $gateway->hostname->value());
        $gateway->installed_version->set($payload['installed_version'] ?? $gateway->installed_version->value());
        $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value());
        $gateway->last_heartbeat_at->set($this->now());
        $gateway->last_seen_ip->set($this->remoteIp());
        $gateway->metadata_json->set($metadata);

        if (isset($payload['inventory']) && is_array($payload['inventory'])) {
            $this->syncDeviceInventory($gatewayId, $payload['inventory']);
        }

        $gatewayPayload = $this->getGateway($gatewayId);
        $gatewayPayload['broker_url'] = $this->buildBrokerPublicUrl();
        edge_gateway_view_cache::syncGateway($gatewayPayload);

        return $gatewayPayload;
    }

    private function mergeHeartbeatBrokerPresence(int $gatewayId, array $existingMetadata, array $payloadMetadata): array
    {
        $metadata = array_merge($existingMetadata, $payloadMetadata);
        if (!array_key_exists('broker_connected', $payloadMetadata)) {
            return $metadata;
        }

        $connected = (bool)$payloadMetadata['broker_connected'];
        $existingPresence = isset($existingMetadata['broker_presence']) && is_array($existingMetadata['broker_presence'])
            ? (array)$existingMetadata['broker_presence']
            : [];
        $presenceMetadata = isset($existingPresence['metadata']) && is_array($existingPresence['metadata'])
            ? (array)$existingPresence['metadata']
            : [];
        $now = $this->now();
        $disconnectReason = isset($payloadMetadata['broker_disconnect_reason'])
            ? trim((string)$payloadMetadata['broker_disconnect_reason'])
            : '';
        $lastError = isset($payloadMetadata['broker_last_error'])
            ? trim((string)$payloadMetadata['broker_last_error'])
            : '';

        $presence = [
            'gateway_id' => $gatewayId,
            'connected' => $connected,
            'connection_id' => isset($existingPresence['connection_id']) && trim((string)$existingPresence['connection_id']) !== ''
                ? (string)$existingPresence['connection_id']
                : null,
            'last_seen_at' => $now,
            'disconnect_reason' => $connected ? null : ($disconnectReason !== '' ? $disconnectReason : null),
            'last_error' => $connected ? null : ($lastError !== '' ? $lastError : ($disconnectReason !== '' ? $disconnectReason : null)),
            'metadata' => array_merge($presenceMetadata, array_filter([
                'agent_instance_id' => $payloadMetadata['agent_instance_id'] ?? null,
                'broker_url' => $payloadMetadata['broker_url'] ?? null,
            ], static fn(mixed $value): bool => $value !== null && $value !== '')),
        ];

        $metadata['broker_presence'] = $presence;
        $metadata['broker_connected'] = $connected;
        if ($connected) {
            $metadata['broker_connected_at'] = $metadata['broker_connected_at'] ?? $now;
            $metadata['broker_last_error'] = null;
        } else {
            $metadata['broker_disconnected_at'] = $now;
            $metadata['broker_last_error'] = $lastError !== '' ? $lastError : ($disconnectReason !== '' ? $disconnectReason : null);
        }

        $this->writeBrokerPresence($gatewayId, $presence);

        return $metadata;
    }

    /**
     * @return array<int,array<string,mixed>>
     * @throws Exception
     */
    public function listGateways(?int $departmentId = null, bool $includeDetail = true): array
    {
        $gatewayObject = new edge_gateways_o();
        $rows = $departmentId === null
            ? $gatewayObject->getFieldsWhere(['deleted_at' => null], ['id'])
            : $gatewayObject->getFieldsWhere(['department_id' => $departmentId, 'deleted_at' => null], ['id']);

        $gateways = [];
        $gatewayIds = [];
        foreach ($rows as $row) {
            $gatewayId = (int)$row['id'];
            try {
                $gateway = $this->requireGateway($gatewayId);
            } catch (Exception $exception) {
                if ($exception->getMessage() === 'Edge gateway not found') {
                    continue;
                }

                throw $exception;
            }

            $gateways[] = $this->buildGatewayPayload($gateway, $includeDetail);
            $gatewayIds[] = $gatewayId;
        }

        if ($gatewayIds !== []) {
            $gateways = self::attachGatewayCollectionSummaries(
                $gateways,
                $this->aggregateInventoryUsageByGateway($gatewayIds),
                $this->aggregateBindingUsageByGateway($gatewayIds)
            );
        }

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

    /**
     * @param array<int,array<string,mixed>> $gateways
     * @return array<string,mixed>
     * @throws Exception
     */
    public function buildFleetUsageStatistics(?int $departmentId = null, array $gateways = []): array
    {
        $fleet = $gateways !== [] ? $gateways : $this->listGateways($departmentId, false);

        return self::summarizeFleetUsageFromGatewayRows($fleet);
    }

    /**
     * @param array<int,array<string,mixed>> $gateways
     * @return array<string,mixed>
     */
    public static function summarizeFleetUsage(array $gateways, array $inventoryUsage = [], array $bindingUsage = []): array
    {
        $inventory = $inventoryUsage === []
            ? self::aggregateInventoryUsageFromGatewayRows($gateways)
            : array_merge(self::emptyInventoryUsage(), $inventoryUsage);
        $bindings = $bindingUsage === []
            ? self::aggregateBindingUsageFromGatewayRows($gateways)
            : array_merge(self::emptyBindingUsage(), $bindingUsage);
        $totalGateways = count($gateways);
        $departmentIds = [];
        $gatewayOnline = 0;
        $gatewayOffline = 0;
        $gatewayDegraded = 0;
        $gatewayDrifted = 0;
        $brokerConnected = 0;
        $activeOperations = 0;
        $pendingOperations = 0;
        $inProgressOperations = 0;
        $operationBacklog = 0;
        $commandBacklog = 0;
        $latencyValues = [];
        $cpuValues = [];
        $memoryValues = [];
        $diskValues = [];

        foreach ($gateways as $gateway) {
            $departmentId = (int)($gateway['department_id'] ?? 0);
            if ($departmentId > 0) {
                $departmentIds[$departmentId] = true;
            }

            $status = strtoupper((string)($gateway['status'] ?? self::STATUS_OFFLINE));
            if ($status === self::STATUS_ONLINE) {
                $gatewayOnline += 1;
            } elseif ($status === self::STATUS_DEGRADED) {
                $gatewayDegraded += 1;
            } else {
                $gatewayOffline += 1;
            }

            if (!empty($gateway['version_drift']['is_drifted'])) {
                $gatewayDrifted += 1;
            }

            if (!empty($gateway['channel_status']['broker']['connected'])) {
                $brokerConnected += 1;
            }

            if (!empty($gateway['active_operation'])) {
                $activeOperations += 1;
            }

            $recentSummary = isset($gateway['recent_operations_summary']) && is_array($gateway['recent_operations_summary'])
                ? (array)$gateway['recent_operations_summary']
                : [];
            $pendingOperations += (int)($recentSummary['pending'] ?? 0);
            $inProgressOperations += (int)($recentSummary['in_progress'] ?? 0);

            $backlog = isset($gateway['backlog_depth']) && is_array($gateway['backlog_depth'])
                ? (array)$gateway['backlog_depth']
                : [];
            $operationBacklog += (int)($backlog['operations'] ?? 0);
            $commandBacklog += (int)($backlog['commands'] ?? 0);

            $metrics = isset($gateway['metadata']['system_metrics']) && is_array($gateway['metadata']['system_metrics'])
                ? (array)$gateway['metadata']['system_metrics']
                : [];
            self::appendNumericMetric($latencyValues, $metrics['latency_ms'] ?? null);
            self::appendNumericMetric($cpuValues, $metrics['cpu_usage_pct'] ?? null);
            self::appendNumericMetric($memoryValues, $metrics['memory_usage_pct'] ?? null);
            self::appendNumericMetric($diskValues, $metrics['disk_usage_pct'] ?? null);
        }

        return [
            'gateways' => [
                'total' => $totalGateways,
                'departments' => count($departmentIds),
                'online' => $gatewayOnline,
                'offline' => $gatewayOffline,
                'degraded' => $gatewayDegraded,
                'drifted' => $gatewayDrifted,
                'broker_connected' => $brokerConnected,
            ],
            'inventory' => $inventory,
            'bindings' => $bindings,
            'operations' => [
                'active' => $activeOperations,
                'pending' => $pendingOperations,
                'in_progress' => $inProgressOperations,
                'backlog' => $operationBacklog,
            ],
            'commands' => [
                'backlog' => $commandBacklog,
            ],
            'system' => [
                'latency_ms_avg' => self::averageMetric($latencyValues),
                'cpu_usage_pct_avg' => self::averageMetric($cpuValues),
                'memory_usage_pct_avg' => self::averageMetric($memoryValues),
                'disk_usage_pct_avg' => self::averageMetric($diskValues),
            ],
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $gateways
     * @return array<string,mixed>
     */
    public static function summarizeFleetUsageFromGatewayRows(array $gateways): array
    {
        return self::summarizeFleetUsage(
            $gateways,
            self::aggregateInventoryUsageFromGatewayRows($gateways),
            self::aggregateBindingUsageFromGatewayRows($gateways)
        );
    }

    /**
     * @param array<string,mixed> $gateway
     * @return array<string,mixed>
     */
    public static function prepareGatewayForListCache(array $gateway, bool $includeDetail): array
    {
        $gateway = self::decorateGatewayUsageSummaries($gateway);

        if ($includeDetail) {
            return $gateway;
        }

        $gateway['inventory'] = [];
        $gateway['bindings'] = [];
        $gateway['recent_commands'] = [];
        $gateway['audit_logs'] = [];
        if (isset($gateway['operations']) && is_array($gateway['operations'])) {
            $gateway['operations'] = array_map(static function (mixed $operation): mixed {
                if (!is_array($operation)) {
                    return $operation;
                }

                $operation['events'] = [];
                return $operation;
            }, array_slice($gateway['operations'], 0, 5));
        }

        return $gateway;
    }

    /**
     * @throws Exception
     */
    public function getGateway(int $gatewayId): array
    {
        $gateway = $this->requireGateway($gatewayId);
        return self::decorateGatewayUsageSummaries($this->buildGatewayPayload($gateway, true));
    }

    /**
     * @throws Exception
     */
    private function buildGatewayPayload(edge_gateways_o $gateway, bool $includeDetail): array
    {
        $data = $gateway->asArray();
        $operations = new edge_gateway_operation_service($this);
        $data['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id);
        $gatewayId = (int)$gateway->id;
        $this->expireTimedOutRelayStatusCommandJobs($gatewayId);
        if ($includeDetail) {
            $data['inventory'] = $this->listInventory($gatewayId);
            $data['bindings'] = $this->listBindings($gatewayId);
            $data['recent_commands'] = $this->listRecentObjects(new edge_gateway_command_jobs_o(), ['gateway_id' => $gatewayId, 'deleted_at' => null]);
            $data['audit_logs'] = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId]);
            $data['operations'] = $operations->listOperations($gatewayId, 12, true);
        } else {
            $data['inventory'] = [];
            $data['bindings'] = [];
            $data['recent_commands'] = [];
            $data['audit_logs'] = [];
            $data['operations'] = $operations->listOperations($gatewayId, 5, false);
        }
        $data['active_operation'] = $operations->getActiveOperation($gatewayId, false);
        $data['recent_operations_summary'] = $operations->buildRecentOperationsSummary($gatewayId);
        $data['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value());
        $data['operational_snapshot'] = $this->buildGatewayOperationalSnapshot((int)$gateway->id);
        return self::deriveGatewayRuntimeState($data);
    }

    /**
     * @throws Exception
     */
    public function updateGatewayMetadata(int $gatewayId, array $payload, ?int $userId = null): array
    {
        $gateway = $this->requireGateway($gatewayId);
        $previousLabel = (string)$gateway->label->value();
        $previousIsPrimary = (bool)$gateway->is_primary->value();
        $label = trim((string)($payload['label'] ?? $previousLabel));
        if ($label === '') {
            throw new Exception('Gateway label is required');
        }

        $isPrimary = (bool)($payload['is_primary'] ?? $previousIsPrimary);
        $gateway->label->set($label);

        if ($isPrimary) {
            $this->setGatewayPrimaryState($gateway, true);
        } elseif ($isPrimary !== $previousIsPrimary) {
            $this->setGatewayPrimaryState($gateway, false);
        }

        $this->writeAudit(
            $gatewayId,
            (int)$gateway->department_id->value(),
            'GATEWAY_METADATA_UPDATED',
            $userId,
            [
                'label' => $label,
                'previous_label' => $previousLabel,
                'is_primary' => $isPrimary,
                'previous_is_primary' => $previousIsPrimary,
            ]
        );

        $gatewayPayload = $this->getGateway($gatewayId);
        edge_gateway_view_cache::syncGateway($gatewayPayload);

        return $gatewayPayload;
    }

    /**
     * @throws Exception
     */
    public function setDepartmentTransportMode(int $departmentId, string $transportMode, ?int $userId = null): array
    {
        if (!in_array($transportMode, [self::TRANSPORT_MODE_CLOUD, self::TRANSPORT_MODE_GATEWAY], true)) {
            throw new Exception('Invalid transport mode');
        }

        $department = $this->requireDepartment($departmentId);
        $department->variables->set(self::DEPARTMENT_VARIABLE_TRANSPORT_MODE, $transportMode);
        $this->writeAudit(
            null,
            $departmentId,
            'DEPARTMENT_TRANSPORT_MODE_UPDATED',
            $userId,
            ['transport_mode' => $transportMode]
        );

        edge_gateway_view_cache::clearAll();

        return [
            'department_id' => $departmentId,
            'transport_mode' => $transportMode,
        ];
    }

    public function getDepartmentTransportMode(int $departmentId): string
    {
        $variables = (new department_variables_o())->selectDepartment($departmentId);
        $mode = $variables->getVariable(self::DEPARTMENT_VARIABLE_TRANSPORT_MODE);
        if ($mode === self::TRANSPORT_MODE_GATEWAY) {
            return self::TRANSPORT_MODE_GATEWAY;
        }
        return self::TRANSPORT_MODE_CLOUD;
    }

    /**
     * @return array<int,array<string,mixed>>
     * @throws Exception
     */
    public function setRelayBindings(int $gatewayId, array $bindings, ?int $userId = null): array
    {
        $gateway = $this->requireGateway($gatewayId);
        $departmentId = (int)$gateway->department_id->value();
        $incomingRelayIds = [];

        foreach ($bindings as $binding) {
            $relayId = trim((string)($binding['relay_id'] ?? ''));
            $deviceId = trim((string)($binding['device_id'] ?? ''));
            if ($relayId === '' || $deviceId === '') {
                throw new Exception('Each relay binding must contain relay_id and device_id');
            }

            $bindingMetadata = $this->normalizeRelayBindingMetadata((array)($binding['metadata'] ?? []), $binding);

            $incomingRelayIds[] = $relayId;
            $existing = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
                'gateway_id' => $gatewayId,
                'relay_id' => $relayId,
            ], ['id']);

            if ($existing !== []) {
                $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existing[0]['id']);
                $existingDeviceId = trim((string)$bindingObject->device_id->value());
                $localIp = $this->resolveRelayBindingLocalIp(
                    $gatewayId,
                    $binding,
                    $deviceId,
                    $existingDeviceId === $deviceId ? $bindingObject->local_ip->value() : null
                );
                $bindingObject->device_id->set($deviceId);
                $bindingObject->local_ip->set($localIp);
                $bindingObject->channel->set((int)($binding['channel'] ?? 0));
                $bindingObject->binding_source->set((string)($binding['binding_source'] ?? 'MANUAL'));
                $bindingObject->approved_by->set($userId);
                $bindingObject->approved_at->set($this->now());
                $bindingObject->metadata_json->set($bindingMetadata);
                $bindingObject->deleted_at->set(null);
                continue;
            }

            $localIp = $this->resolveRelayBindingLocalIp($gatewayId, $binding, $deviceId);
            (new edge_gateway_relay_bindings_o())->add_object([
                'gateway_id' => $gatewayId,
                'department_id' => $departmentId,
                'relay_id' => $relayId,
                'device_id' => $deviceId,
                'local_ip' => $localIp,
                'channel' => (int)($binding['channel'] ?? 0),
                'binding_source' => (string)($binding['binding_source'] ?? 'MANUAL'),
                'approved_by' => $userId,
                'approved_at' => $this->now(),
                'metadata_json' => $bindingMetadata,
            ]);
        }

        foreach ($this->listBindings($gatewayId) as $existingBinding) {
            if (in_array((string)$existingBinding['relay_id'], $incomingRelayIds, true)) {
                continue;
            }
            $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$existingBinding['id']);
            $bindingObject->deleted_at->set($this->now());
        }

        $this->writeAudit(
            $gatewayId,
            $departmentId,
            'RELAY_BINDINGS_UPDATED',
            $userId,
            ['binding_count' => count($bindings)]
        );

        edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId));

        return $this->listBindings($gatewayId);
    }

    /**
     * @throws Exception
     */
    public function queueDiscovery(int $gatewayId, ?int $userId = null): array
    {
        $gateway = $this->requireGateway($gatewayId);
        $gateway->discovery_status->set('PENDING');
        $this->createCommandJob($gatewayId, 'DISCOVER_SHELLY', [], $userId, [
            'preferred_channel' => $this->resolveGatewayPreferredCommandChannel($gateway),
        ]);

        $gatewayPayload = $this->getGateway($gatewayId);
        edge_gateway_view_cache::syncGateway($gatewayPayload);

        return $gatewayPayload;
    }

    /**
     * @throws Exception
     */
    public function deleteGateway(int $gatewayId, ?int $userId = null): array
    {
        $gateway = $this->requireGateway($gatewayId);
        $departmentId = (int)$gateway->department_id->value();
        $label = (string)$gateway->label->value();
        if ((bool)$gateway->is_primary->value()) {
            $replacement = $this->findAlternateGatewayForDepartment($departmentId, $gatewayId);
            if ($replacement !== null) {
                $replacement->is_primary->set(true);
            }
        }

        $this->softDeleteGatewayRelations($gatewayId);
        $gateway->deleted_at->set($this->now());

        $this->writeAudit(
            $gatewayId,
            $departmentId,
            'GATEWAY_DELETED',
            $userId,
            ['label' => $label]
        );

        edge_gateway_view_cache::removeGateway($gatewayId, $departmentId);

        return [
            'deleted' => true,
            'gateway_id' => $gatewayId,
            'department_id' => $departmentId,
        ];
    }

    public function deleteOrphanedGatewaysForDepartment(int $departmentId): void
    {
        if ($departmentId <= 0 || $this->departmentExists($departmentId)) {
            return;
        }

        $rows = (new edge_gateways_o())->getFieldsWhere([
            'department_id' => $departmentId,
            'deleted_at' => null,
        ], ['id']);

        foreach ($rows as $row) {
            $gatewayId = (int)($row['id'] ?? 0);
            if ($gatewayId <= 0) {
                continue;
            }

            try {
                $gateway = (new edge_gateways_o())->select($gatewayId);
                if ($gateway->exists()) {
                    $this->softDeleteOrphanedGateway($gateway);
                }
            } catch (\Throwable) {
                edge_gateway_view_cache::removeGateway($gatewayId, $departmentId);
            }
        }
    }

    /**
     * @throws Exception
     */
    public function pollCommand(int $gatewayId, string $plainToken, int $waitSeconds = self::COMMAND_POLL_TIMEOUT_SECONDS): ?array
    {
        $gateway = $this->authenticateGateway($gatewayId, $plainToken);
        $deadline = microtime(true) + max(0, $waitSeconds);

        do {
            $job = $this->claimNextCommandJob($gateway);
            if ($job !== null) {
                return $this->formatAgentCommandJob($job, $gateway);
            }

            if (microtime(true) >= $deadline) {
                break;
            }

            usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
        } while (true);

        return null;
    }

    /**
     * @throws Exception
     */
    public function submitCommandResult(
        int $gatewayId,
        int $jobId,
        string $plainToken,
        bool $ok,
        array $payload = [],
        ?string $error = null
    ): array {
        $gateway = $this->authenticateGateway($gatewayId, $plainToken);
        $job = (new edge_gateway_command_jobs_o())->select($jobId);
        if (!$job->exists()) {
            throw new Exception('Edge gateway command job not found');
        }
        if ((int)$job->gateway_id->value() !== (int)$gateway->id) {
            throw new Exception('Edge gateway command job does not belong to this gateway');
        }

        $status = (string)$job->status->value();
        if (in_array($status, ['COMPLETED', 'FAILED', 'TIMED_OUT'], true)) {
            return [
                'acknowledged' => true,
                'job' => $job->asArray(),
            ];
        }

        $errorMessage = $ok ? null : trim((string)$error);
        if (!$ok && $errorMessage === '') {
            $errorMessage = 'Edge gateway command failed';
        }

        $this->finalizeCommandJob($job, $ok, $payload, $errorMessage, $gateway);

        edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId));

        return [
            'acknowledged' => true,
            'job' => $job->asArray(),
        ];
    }

    /**
     * @throws Exception
     */
    public function buildExpectedRelayStatesForAgent(
        int $gatewayId,
        string $plainToken,
        int $waitSeconds = 0
    ): array {
        $gateway = $this->authenticateGateway($gatewayId, $plainToken);
        $deadline = microtime(true) + max(0, min($waitSeconds, self::COMMAND_POLL_TIMEOUT_SECONDS));

        do {
            $payload = $this->buildExpectedRelayStatesPayload($gateway);
            if ($this->payloadHasPendingExpectedRelayState($payload) || microtime(true) >= $deadline) {
                return $payload;
            }

            usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
        } while (true);
    }

    /**
     * @param array<int,array<string,mixed>> $results
     * @return array<string,mixed>
     * @throws Exception
     */
    public function recordExpectedRelayStateResults(int $gatewayId, string $plainToken, array $results): array
    {
        $this->authenticateGateway($gatewayId, $plainToken);
        $acknowledged = [];
        $rejected = [];
        $completed = 0;
        $skipped = 0;
        $failed = 0;
        $stale = 0;

        foreach ($results as $index => $result) {
            if (!is_array($result)) {
                $rejected[] = ['index' => $index, 'reason' => 'INVALID_RESULT'];
                continue;
            }

            $laneId = (int)($result['lane_id'] ?? $result['laneId'] ?? 0);
            $role = $this->normalizeExpectedRelayRole($result['role'] ?? $result['target'] ?? $result['relay'] ?? '');
            if ($laneId <= 0 || $role === null) {
                $rejected[] = ['index' => $index, 'reason' => 'INVALID_LANE_OR_ROLE'];
                continue;
            }

            $row = $this->findExpectedRelayStateRow($gatewayId, $laneId, $role);
            if ($row === null) {
                $rejected[] = ['index' => $index, 'lane_id' => $laneId, 'role' => $role, 'reason' => 'UNKNOWN_STATE'];
                continue;
            }

            $reportedUpdated = isset($result['updated']) ? (string)$result['updated'] : null;
            if (!$this->expectedRelayStateUpdateMatches((string)$row['updated_at'], $reportedUpdated)) {
                $stale++;
                $rejected[] = ['index' => $index, 'lane_id' => $laneId, 'role' => $role, 'reason' => 'STALE_STATE'];
                continue;
            }

            $ok = (bool)($result['ok'] ?? false);
            $isSkipped = (bool)($result['skipped'] ?? false);
            $error = trim((string)($result['error'] ?? ''));
            $completedAt = $ok
                ? $this->normalizeAgentCompletedAt($result['completed'] ?? null)
                : null;

            $this->updateExpectedRelayStateResult(
                (int)$row['id'],
                $ok,
                $completedAt,
                $error !== '' ? $error : ($ok ? null : 'Relay state was not completed by the agent'),
                isset($result['metadata']) && is_array($result['metadata']) ? (array)$result['metadata'] : []
            );

            if ($ok) {
                $completed++;
            } elseif ($isSkipped) {
                $skipped++;
            } else {
                $failed++;
            }

            $acknowledged[] = [
                'lane_id' => $laneId,
                'role' => $role,
                'completed' => $completedAt,
                'skipped' => $isSkipped,
                'ok' => $ok,
            ];
        }

        edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId));

        return [
            'acknowledged' => $acknowledged,
            'rejected' => $rejected,
            'summary' => [
                'received' => count($results),
                'completed' => $completed,
                'skipped' => $skipped,
                'failed' => $failed,
                'stale' => $stale,
                'rejected' => count($rejected),
            ],
        ];
    }

    private function buildExpectedRelayStatesPayload(edge_gateways_o $gateway): array
    {
        $gatewayId = (int)$gateway->id;
        $departmentId = (int)$gateway->department_id->value();
        $expectedRows = $this->readExpectedRelayStateRows($gatewayId);
        $bindings = $this->readRelayBindingsByRelayId($gatewayId, $departmentId);
        $lanes = $this->readDepartmentLanesForExpectedStates($departmentId);
        $lanePayload = [];

        foreach ($lanes as $lane) {
            $laneId = (int)($lane['id'] ?? 0);
            if ($laneId <= 0) {
                continue;
            }

            $lanePayload[(string)$laneId] = [];
            foreach (self::EXPECTED_RELAY_ROLE_COLUMNS as $role => $column) {
                $relayId = trim((string)($lane[$column] ?? ''));
                $expected = $expectedRows[$laneId][$role] ?? null;
                $binding = $relayId !== '' ? ($bindings[$relayId] ?? []) : [];
                $metadata = $expected !== null
                    ? $this->decodeJsonArray($expected['metadata_json'] ?? null)
                    : [];
                $updated = (string)($expected['updated_at'] ?? $lane['updated_at'] ?? $lane['created_at'] ?? $this->now());
                $completed = $expected !== null
                    ? ($expected['completed_at'] === null ? null : (string)$expected['completed_at'])
                    : $updated;

                $lanePayload[(string)$laneId][$role] = [
                    'device' => [
                        'ip' => (string)($expected['local_ip'] ?? $binding['local_ip'] ?? ''),
                        'id' => (string)($expected['device_id'] ?? $binding['device_id'] ?? $relayId),
                        'type' => (string)($expected['device_type'] ?? $this->inferExpectedRelayDeviceType($binding, $metadata)),
                    ],
                    'state' => (bool)($expected['expected_state'] ?? false),
                    'updated' => $updated,
                    'completed' => $completed,
                ];

                if ($expected !== null && !empty($expected['last_error'])) {
                    $lanePayload[(string)$laneId][$role]['last_error'] = (string)$expected['last_error'];
                }
                if (isset($metadata['toggle_after'])) {
                    $lanePayload[(string)$laneId][$role]['toggle_after'] = (int)$metadata['toggle_after'];
                }
            }
        }

        $primaryLaneId = null;
        foreach ($lanePayload as $laneId => $states) {
            if ($primaryLaneId === null) {
                $primaryLaneId = (int)$laneId;
            }
            foreach ($states as $state) {
                if (is_array($state) && ($state['completed'] ?? null) === null) {
                    $primaryLaneId = (int)$laneId;
                    break 2;
                }
            }
        }

        $payload = [
            'gateway_id' => $gatewayId,
            'department_id' => $departmentId,
            'server_time' => $this->now(),
            'poll_after_ms' => 1000,
            'lanes' => $lanePayload,
        ];

        if ($primaryLaneId !== null && isset($lanePayload[(string)$primaryLaneId])) {
            $payload['lane_id'] = $primaryLaneId;
            foreach (self::EXPECTED_RELAY_ROLE_COLUMNS as $role => $_column) {
                if (isset($lanePayload[(string)$primaryLaneId][$role])) {
                    $payload[$role] = $lanePayload[(string)$primaryLaneId][$role];
                }
            }
        }

        return $payload;
    }

    private function payloadHasPendingExpectedRelayState(array $payload): bool
    {
        foreach ((array)($payload['lanes'] ?? []) as $lane) {
            if (!is_array($lane)) {
                continue;
            }
            foreach ($lane as $entry) {
                if (is_array($entry) && ($entry['completed'] ?? null) === null) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * @return array<int,array<string,array<string,mixed>>>
     */
    private function readExpectedRelayStateRows(int $gatewayId): array
    {
        $statement = db::getPDO()->prepare(
            "SELECT *
             FROM edge_gateway_expected_relay_states
             WHERE gateway_id = :gateway_id
               AND deleted_at IS NULL"
        );
        $statement->execute([':gateway_id' => $gatewayId]);

        $rows = [];
        foreach ($statement->fetchAll() ?: [] as $row) {
            if (!is_array($row)) {
                continue;
            }
            $laneId = (int)($row['lane_id'] ?? 0);
            $role = $this->normalizeExpectedRelayRole($row['role'] ?? '');
            if ($laneId <= 0 || $role === null) {
                continue;
            }
            $rows[$laneId][$role] = $row;
        }

        return $rows;
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    private function readRelayBindingsByRelayId(int $gatewayId, int $departmentId): array
    {
        $statement = db::getPDO()->prepare(
            "SELECT *
             FROM edge_gateway_relay_bindings
             WHERE gateway_id = :gateway_id
               AND department_id = :department_id
               AND deleted_at IS NULL"
        );
        $statement->execute([
            ':gateway_id' => $gatewayId,
            ':department_id' => $departmentId,
        ]);

        $bindings = [];
        foreach ($statement->fetchAll() ?: [] as $row) {
            if (!is_array($row)) {
                continue;
            }
            $relayId = trim((string)($row['relay_id'] ?? ''));
            if ($relayId !== '') {
                $bindings[$relayId] = $row;
            }
        }

        return $bindings;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function readDepartmentLanesForExpectedStates(int $departmentId): array
    {
        $statement = db::getPDO()->prepare(
            "SELECT id,
                    relay_in_id,
                    relay_out_id,
                    relay_machine_id,
                    relay_machine_program_picker_id,
                    relay_machine_cleaner_id,
                    created_at,
                    updated_at
             FROM department_lanes
             WHERE department = :department_id
               AND deleted_at IS NULL
             ORDER BY id ASC"
        );
        $statement->execute([':department_id' => $departmentId]);

        return array_values(array_filter(
            $statement->fetchAll() ?: [],
            static fn(mixed $row): bool => is_array($row)
        ));
    }

    private function normalizeExpectedRelayRole(mixed $role): ?string
    {
        $normalized = strtoupper(str_replace(['-', ' '], '_', trim((string)$role)));
        return self::EXPECTED_RELAY_ROLE_ALIASES[$normalized] ?? null;
    }

    private function findExpectedRelayStateRow(int $gatewayId, int $laneId, string $role): ?array
    {
        $statement = db::getPDO()->prepare(
            "SELECT *
             FROM edge_gateway_expected_relay_states
             WHERE gateway_id = :gateway_id
               AND lane_id = :lane_id
               AND role = :role
               AND deleted_at IS NULL
             LIMIT 1"
        );
        $statement->execute([
            ':gateway_id' => $gatewayId,
            ':lane_id' => $laneId,
            ':role' => $role,
        ]);
        $row = $statement->fetch();

        return is_array($row) ? $row : null;
    }

    private function expectedRelayStateUpdateMatches(string $storedUpdated, ?string $reportedUpdated): bool
    {
        if ($reportedUpdated === null || trim($reportedUpdated) === '') {
            return false;
        }

        $storedTimestamp = self::parseApplicationDateTime($storedUpdated);
        $reportedTimestamp = self::parseApplicationDateTime($reportedUpdated);
        if ($storedTimestamp !== null && $reportedTimestamp !== null) {
            return $storedTimestamp === $reportedTimestamp;
        }

        return trim($storedUpdated) === trim($reportedUpdated);
    }

    private function normalizeAgentCompletedAt(mixed $completedAt): string
    {
        $timestamp = self::parseApplicationDateTime(is_scalar($completedAt) ? (string)$completedAt : null);
        return $this->formatDateTime($timestamp ?? time());
    }

    private function updateExpectedRelayStateResult(
        int $rowId,
        bool $ok,
        ?string $completedAt,
        ?string $error,
        array $resultMetadata
    ): void {
        $row = $this->queryExpectedRelayStateById($rowId);
        $metadata = $this->decodeJsonArray($row['metadata_json'] ?? null);
        $metadata['last_result'] = [
            'ok' => $ok,
            'error' => $error,
            'metadata' => $resultMetadata,
            'at' => $this->now(),
        ];

        $statement = db::getPDO()->prepare(
            "UPDATE edge_gateway_expected_relay_states
             SET completed_at = :completed_at,
                 last_attempted_at = :last_attempted_at,
                 last_error = :last_error,
                 metadata_json = :metadata_json
             WHERE id = :id"
        );
        $statement->execute([
            ':completed_at' => $ok ? $completedAt : null,
            ':last_attempted_at' => $this->now(),
            ':last_error' => $ok ? null : $error,
            ':metadata_json' => $this->encodeJson($metadata),
            ':id' => $rowId,
        ]);
    }

    private function queryExpectedRelayStateById(int $rowId): array
    {
        $statement = db::getPDO()->prepare(
            "SELECT *
             FROM edge_gateway_expected_relay_states
             WHERE id = :id
             LIMIT 1"
        );
        $statement->execute([':id' => $rowId]);
        $row = $statement->fetch();

        return is_array($row) ? $row : [];
    }

    private function upsertExpectedRelayStateFromCommand(
        int $gatewayId,
        int $departmentId,
        array $command,
        int $jobId
    ): void {
        $laneId = (int)($command['lane_id'] ?? $command['laneId'] ?? 0);
        $role = $this->normalizeExpectedRelayRole($command['target'] ?? $command['role'] ?? '');
        $relayId = trim((string)($command['relay_id'] ?? $command['relayId'] ?? ''));
        if ($laneId <= 0 || $role === null || $relayId === '') {
            return;
        }

        $metadata = [
            'command_job_id' => $jobId,
            'channel' => (int)($command['channel'] ?? 0),
            'target' => (string)($command['target'] ?? $role),
        ];
        if (isset($command['toggle_after']) || isset($command['toggleAfter']) || isset($command['timer'])) {
            $metadata['toggle_after'] = (int)($command['toggle_after'] ?? $command['toggleAfter'] ?? $command['timer']);
        }

        $now = $this->now();
        $statement = db::getPDO()->prepare(
            "INSERT INTO edge_gateway_expected_relay_states (
                gateway_id,
                department_id,
                lane_id,
                role,
                relay_id,
                device_id,
                local_ip,
                device_type,
                expected_state,
                updated_at,
                completed_at,
                last_attempted_at,
                last_error,
                metadata_json
            ) VALUES (
                :gateway_id,
                :department_id,
                :lane_id,
                :role,
                :relay_id,
                :device_id,
                :local_ip,
                :device_type,
                :expected_state,
                :updated_at,
                NULL,
                NULL,
                NULL,
                :metadata_json
            )
            ON DUPLICATE KEY UPDATE
                relay_id = VALUES(relay_id),
                device_id = VALUES(device_id),
                local_ip = VALUES(local_ip),
                device_type = VALUES(device_type),
                expected_state = VALUES(expected_state),
                updated_at = VALUES(updated_at),
                completed_at = NULL,
                last_attempted_at = NULL,
                last_error = NULL,
                metadata_json = VALUES(metadata_json),
                deleted_at = NULL"
        );
        $statement->execute([
            ':gateway_id' => $gatewayId,
            ':department_id' => $departmentId,
            ':lane_id' => $laneId,
            ':role' => $role,
            ':relay_id' => $relayId,
            ':device_id' => trim((string)($command['device_id'] ?? $command['deviceId'] ?? $relayId)),
            ':local_ip' => trim((string)($command['local_ip'] ?? $command['localIp'] ?? '')),
            ':device_type' => $this->inferExpectedRelayDeviceType([], $command),
            ':expected_state' => !empty($command['on']) ? 1 : 0,
            ':updated_at' => $now,
            ':metadata_json' => $this->encodeJson($metadata),
        ]);
    }

    private function inferExpectedRelayDeviceType(array $binding = [], array $metadata = []): string
    {
        $metadata = array_merge($this->decodeJsonArray($binding['metadata_json'] ?? null), $metadata);
        $type = strtoupper(trim((string)($metadata['device_type'] ?? $metadata['type'] ?? '')));
        if (in_array($type, ['SHELLY_SWITCH', 'SHELLY_PRO_SWITCH', 'UNKNOWN'], true)) {
            return $type;
        }

        $model = strtoupper(trim((string)($binding['model'] ?? $metadata['device_model'] ?? $metadata['model'] ?? '')));
        if (str_contains($model, 'PRO')) {
            return 'SHELLY_PRO_SWITCH';
        }
        $deviceId = trim((string)($binding['device_id'] ?? $metadata['device_id'] ?? $metadata['deviceId'] ?? ''));
        if ($model !== '' || $deviceId !== '') {
            return 'SHELLY_SWITCH';
        }

        return 'UNKNOWN';
    }

    private function decodeJsonArray(mixed $value): array
    {
        if (is_array($value)) {
            return $value;
        }
        if (!is_string($value) || trim($value) === '') {
            return [];
        }

        $decoded = json_decode($value, true);
        return is_array($decoded) ? $decoded : [];
    }

    private function encodeJson(array $value): string
    {
        $encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        return is_string($encoded) ? $encoded : '{}';
    }

    /**
     * @throws Exception
     */
    public function resolveRelayBinding(int $departmentId, string $logicalRelayId): array
    {
        $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
            'department_id' => $departmentId,
            'relay_id' => $logicalRelayId,
            'deleted_at' => null,
        ], ['id', 'gateway_id']);

        if ($rows === []) {
            throw new Exception('No edge gateway relay binding found for relay ' . $logicalRelayId);
        }

        $candidates = [];
        foreach ($rows as $row) {
            $binding = (new edge_gateway_relay_bindings_o())->select((int)$row['id']);
            if (!$binding->exists()) {
                continue;
            }

            $gateway = (new edge_gateways_o())->select((int)$row['gateway_id']);
            if (!$gateway->exists() || $gateway->deleted_at->value() !== null) {
                continue;
            }

            $effectiveStatus = self::resolveGatewayStatus(
                $gateway->status->value() === null ? null : (string)$gateway->status->value(),
                $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
            );

            $candidates[] = [
                'binding' => $binding->asArray(),
                'gateway' => $gateway,
                'status' => $effectiveStatus,
            ];
        }

        if ($candidates === []) {
            throw new Exception('No active edge gateway found for relay ' . $logicalRelayId);
        }

        usort($candidates, static function (array $a, array $b) use ($departmentId): int {
            /** @var edge_gateways_o $aGateway */
            $aGateway = $a['gateway'];
            /** @var edge_gateways_o $bGateway */
            $bGateway = $b['gateway'];

            return (((int)$bGateway->department_id->value() === $departmentId) <=> ((int)$aGateway->department_id->value() === $departmentId))
                ?: ((int)$bGateway->is_primary->value() <=> (int)$aGateway->is_primary->value())
                ?: (self::statusPriority((string)$b['status']) <=> self::statusPriority((string)$a['status']))
                ?: (self::heartbeatTimestamp($bGateway->last_heartbeat_at->value() === null ? null : (string)$bGateway->last_heartbeat_at->value())
                    <=> self::heartbeatTimestamp($aGateway->last_heartbeat_at->value() === null ? null : (string)$aGateway->last_heartbeat_at->value()));
        });

        return $candidates[0]['binding'];
    }

    /**
     * @throws Exception
     */
    public function dispatchRelayStatus(int $departmentId, string $logicalRelayId, array $actionContext = []): array
    {
        return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, false, $actionContext);
    }

    /**
     * @throws Exception
     */
    public function dispatchRelayStatusLocalOnly(int $departmentId, string $logicalRelayId, array $actionContext = []): array
    {
        return $this->dispatchRelayStatusWithOptions($departmentId, $logicalRelayId, true, $actionContext);
    }

    /**
     * @throws Exception
     */
    private function dispatchRelayStatusWithOptions(
        int $departmentId,
        string $logicalRelayId,
        bool $requireFastLocalPath = false,
        array $actionContext = []
    ): array
    {
        $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
        $gateway = $this->requireGateway((int)$binding['gateway_id']);
        $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId);
        if ($requireFastLocalPath) {
            $resolution = $this->forceLocalRelayExecutionPlan($resolution);
        }
        $actionContext = $this->normalizeRelayActionContext($actionContext);

        if (($resolution['execution_path'] ?? 'local') === 'cloud') {
            return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, null, $binding, $resolution, null, $actionContext);
        }

        $statusRequest = [
            'relayId' => $logicalRelayId,
            'deviceId' => $binding['device_id'],
            'localIp' => $binding['local_ip'],
            'channel' => (int)$binding['channel'],
        ];
        $deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding);
        if ($deviceGeneration !== null) {
            $statusRequest['deviceGeneration'] = $deviceGeneration;
            $statusRequest['device_generation'] = $deviceGeneration;
        }

        $job = $this->createCommandJob((int)$gateway->id, 'GET_RELAY_STATUS', $statusRequest, $this->resolveRelayRequestedBy($actionContext), [
            'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
            'fallback_reason' => $resolution['reason'] ?? null,
            'require_fast_path' => $requireFastLocalPath,
        ]);
        $dispatchLog = [
            'action' => 'STATUS',
            'handler' => 'local',
            'relay_id' => $logicalRelayId,
            'signal' => $this->buildRelayCommandSignal($job, $statusRequest),
            'action_context' => $actionContext,
        ];

        try {
            $result = $this->dispatchGatewayCommand($gateway, $job);
            return $this->finalizeRelayDispatch($binding, $resolution, $result, $dispatchLog);
        } catch (Exception $exception) {
            $this->appendRelayDispatchLog($binding, $resolution, false, $dispatchLog, [], $exception);
            return $this->handleRelayDispatchFailure(
                $departmentId,
                $logicalRelayId,
                $binding,
                $resolution,
                null,
                $exception,
                null,
                $actionContext
            );
        }
    }

    /**
     * @throws Exception
     */
    public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array
    {
        return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, false, null, $actionContext);
    }

    /**
     * @throws Exception
     */
    public function dispatchRelaySwitchWithTimer(
        int $departmentId,
        string $logicalRelayId,
        bool $on,
        ?int $toggleAfterSeconds,
        array $actionContext = []
    ): array {
        return $this->dispatchRelaySwitchWithOptions(
            $departmentId,
            $logicalRelayId,
            $on,
            false,
            $this->normalizeRelayToggleAfter($toggleAfterSeconds),
            $actionContext
        );
    }

    /**
     * @throws Exception
     */
    public function dispatchRelaySwitchLocalOnly(int $departmentId, string $logicalRelayId, bool $on, array $actionContext = []): array
    {
        return $this->dispatchRelaySwitchWithOptions($departmentId, $logicalRelayId, $on, true, null, $actionContext);
    }

    /**
     * @throws Exception
     */
    public function dispatchRelaySwitchLocalOnlyWithTimer(
        int $departmentId,
        string $logicalRelayId,
        bool $on,
        ?int $toggleAfterSeconds,
        array $actionContext = []
    ): array {
        return $this->dispatchRelaySwitchWithOptions(
            $departmentId,
            $logicalRelayId,
            $on,
            true,
            $this->normalizeRelayToggleAfter($toggleAfterSeconds),
            $actionContext
        );
    }

    /**
     * @param array<int,array<string,mixed>> $requests
     * @throws Exception
     */
    public function queueRelayStatusBatch(
        int $departmentId,
        array $requests,
        ?int $userId = null,
        bool $requireFastLocalPath = false
    ): array {
        return $this->queueRelayBatch(
            $departmentId,
            self::COMMAND_BATCH_RELAY_STATUS,
            $requests,
            $userId,
            $requireFastLocalPath
        );
    }

    /**
     * @param array<int,array<string,mixed>> $requests
     * @throws Exception
     */
    public function queueRelaySwitchBatch(
        int $departmentId,
        array $requests,
        ?int $userId = null,
        bool $requireFastLocalPath = false
    ): array {
        return $this->queueRelayBatch(
            $departmentId,
            self::COMMAND_BATCH_SET_RELAY_STATE,
            $requests,
            $userId,
            $requireFastLocalPath
        );
    }

    /**
     * @return array<string,mixed>
     * @throws Exception
     */
    public function relayBatchStatus(string $batchId): array
    {
        $batchId = trim($batchId);
        if ($batchId === '' || !preg_match('/^[a-f0-9]{32}$/', $batchId)) {
            throw new Exception('Invalid relay batch id');
        }

        $statement = db::getPDO()->prepare(
            "SELECT id
             FROM edge_gateway_command_jobs
             WHERE deleted_at IS NULL
               AND JSON_UNQUOTE(JSON_EXTRACT(delivery_json, '$.batch_id')) = :batch_id
             ORDER BY id ASC
             LIMIT 10"
        );
        $statement->execute([':batch_id' => $batchId]);

        $jobs = [];
        foreach ($statement->fetchAll() ?: [] as $row) {
            $jobId = (int)($row['id'] ?? 0);
            if ($jobId > 0) {
                $jobs[] = (new edge_gateway_command_jobs_o())->select($jobId);
            }
        }

        if ($jobs === []) {
            throw new Exception('Relay batch not found');
        }

        $items = [];
        $pending = 0;
        $failed = 0;
        $completed = 0;
        $unknownOutcomes = 0;

        foreach ($jobs as $job) {
            if (!$job->exists()) {
                continue;
            }

            $delivery = (array)($job->delivery_json->value() ?? []);
            if (
                (string)$job->status->value() === 'DISPATCHING'
                && !empty($delivery['no_auto_retry'])
                && self::parseApplicationDateTime((string)($job->updated_at->value() ?? $job->requested_at->value())) <= time() - self::COMMAND_DISPATCH_STALE_AFTER_SECONDS
            ) {
                $this->finalizeCommandJob(
                    $job,
                    false,
                    [],
                    'Edge gateway command outcome is unknown after dispatch interruption; not retrying non-idempotent gate pulse',
                    null,
                    'TIMED_OUT'
                );
            }

            $status = strtoupper((string)$job->status->value());
            $outcomeUnknown = $status === 'TIMED_OUT' && !empty($delivery['no_auto_retry']);
            if ($outcomeUnknown) {
                $unknownOutcomes++;
            }
            if (in_array($status, ['PENDING', 'DISPATCHING'], true)) {
                $pending++;
            } elseif ($status === 'COMPLETED') {
                $completed++;
            } else {
                $failed++;
            }

            $request = (array)($job->request_json->value() ?? []);
            $response = (array)($job->response_json->value() ?? []);
            $payload = (array)($response['payload'] ?? []);
            $resultItems = isset($payload['results']) && is_array($payload['results'])
                ? (array)$payload['results']
                : [];
            if ($resultItems === [] && $status === 'COMPLETED') {
                $resultItems = [[
                    'target' => (string)($request['target'] ?? ''),
                    'relayId' => (string)($request['relayId'] ?? $request['relay_id'] ?? ''),
                    'relay_id' => (string)($request['relay_id'] ?? $request['relayId'] ?? ''),
                    'ok' => true,
                    'payload' => $payload,
                ]];
            }

            if ($resultItems === [] && $status !== 'COMPLETED') {
                $items[] = [
                    'target' => (string)($request['target'] ?? ''),
                    'relay_id' => (string)($request['relayId'] ?? $request['relay_id'] ?? ''),
                    'status' => $status,
                    'ok' => false,
                    'outcome_unknown' => $outcomeUnknown,
                    'error' => $job->error_message->value() === null ? null : (string)$job->error_message->value(),
                ];
                continue;
            }

            foreach ($resultItems as $item) {
                if (!is_array($item)) {
                    continue;
                }
                $items[] = $item + [
                    'status' => !empty($item['ok']) ? 'COMPLETED' : 'FAILED',
                    'outcome_unknown' => $outcomeUnknown,
                ];
            }
        }

        $itemFailures = count(array_filter($items, static fn(array $item): bool => empty($item['ok']) && (($item['status'] ?? '') !== 'PENDING')));
        $status = 'PENDING';
        if ($pending > 0 && ($completed > 0 || $failed > 0)) {
            $status = 'RUNNING';
        } elseif ($pending === 0 && $unknownOutcomes > 0 && $completed === 0) {
            $status = 'UNKNOWN_OUTCOME';
        } elseif ($pending === 0 && ($failed > 0 || $itemFailures > 0) && ($completed > 0 || count($items) > $itemFailures)) {
            $status = 'PARTIAL';
        } elseif ($pending === 0 && ($failed > 0 || $itemFailures > 0)) {
            $status = 'FAILED';
        } elseif ($pending === 0) {
            $status = 'COMPLETED';
        }

        return [
            'batch_id' => $batchId,
            'status' => $status,
            'unknown_outcome_count' => $unknownOutcomes,
            'jobs' => array_map(static fn(edge_gateway_command_jobs_o $job): array => $job->asArray(), $jobs),
            'items' => $items,
        ];
    }

    /**
     * @throws Exception
     */
    private function dispatchRelaySwitchWithOptions(
        int $departmentId,
        string $logicalRelayId,
        bool $on,
        bool $requireFastLocalPath = false,
        ?int $toggleAfterSeconds = null,
        array $actionContext = []
    ): array
    {
        $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
        $gateway = $this->requireGateway((int)$binding['gateway_id']);
        $resolution = $this->resolveRelayExecutionPlan($gateway, $binding, $logicalRelayId);
        if ($requireFastLocalPath) {
            $resolution = $this->forceLocalRelayExecutionPlan($resolution);
        }
        $actionContext = $this->normalizeRelayActionContext($actionContext);

        if (($resolution['execution_path'] ?? 'local') === 'cloud') {
            return $this->dispatchRelayThroughCloud($departmentId, $logicalRelayId, $on, $binding, $resolution, $toggleAfterSeconds, $actionContext);
        }

        $request = [
            'relayId' => $logicalRelayId,
            'deviceId' => $binding['device_id'],
            'localIp' => $binding['local_ip'],
            'channel' => (int)$binding['channel'],
            'on' => $on,
        ];
        $deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding);
        if ($deviceGeneration !== null) {
            $request['deviceGeneration'] = $deviceGeneration;
            $request['device_generation'] = $deviceGeneration;
        }
        if ($toggleAfterSeconds !== null) {
            $request['toggleAfter'] = $toggleAfterSeconds;
            $request['toggle_after'] = $toggleAfterSeconds;
        }

        $job = $this->createCommandJob((int)$gateway->id, 'SET_RELAY_STATE', $request, $this->resolveRelayRequestedBy($actionContext), [
            'preferred_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
            'fallback_reason' => $resolution['reason'] ?? null,
            'require_fast_path' => $requireFastLocalPath,
        ]);
        $dispatchLog = [
            'action' => 'SWITCH',
            'handler' => 'local',
            'relay_id' => $logicalRelayId,
            'target_on' => $on,
            'toggle_after_seconds' => $toggleAfterSeconds,
            'signal' => $this->buildRelayCommandSignal($job, $request),
            'action_context' => $actionContext,
        ];

        try {
            $result = $this->dispatchGatewayCommand($gateway, $job);
            return $this->finalizeRelayDispatch($binding, $resolution, $result, $dispatchLog);
        } catch (Exception $exception) {
            $this->appendRelayDispatchLog($binding, $resolution, false, $dispatchLog, [], $exception);
            return $this->handleRelayDispatchFailure(
                $departmentId,
                $logicalRelayId,
                $binding,
                $resolution,
                $on,
                $exception,
                $toggleAfterSeconds,
                $actionContext
            );
        }
    }

    /**
     * @param array<int,array<string,mixed>> $requests
     * @return array<string,mixed>
     * @throws Exception
     */
    private function queueRelayBatch(
        int $departmentId,
        string $commandType,
        array $requests,
        ?int $userId,
        bool $requireFastLocalPath
    ): array {
        if ($departmentId <= 0) {
            throw new Exception('A department_id is required for relay batches');
        }
        if ($requests === []) {
            throw new Exception('At least one relay batch command is required');
        }
        if (count($requests) > 5) {
            throw new Exception('Relay batch commands are limited to five targets');
        }

        $batchId = bin2hex(random_bytes(16));
        $commands = [];
        $jobs = [];
        $gateway = null;
        $seenTargets = [];

        foreach ($requests as $request) {
            if (!is_array($request)) {
                throw new Exception('Invalid relay batch command');
            }

            $target = strtoupper(trim((string)($request['target'] ?? $request['relay'] ?? '')));
            $logicalRelayId = trim((string)($request['relay_id'] ?? $request['relayId'] ?? ''));
            if ($target === '' || $logicalRelayId === '') {
                throw new Exception('Relay batch commands require target and relay_id');
            }
            if (isset($seenTargets[$target])) {
                throw new Exception('Duplicate relay batch target: ' . $target);
            }
            $seenTargets[$target] = true;

            $binding = $this->resolveRelayBinding($departmentId, $logicalRelayId);
            $commandGateway = $this->requireGateway((int)$binding['gateway_id']);
            if ($gateway === null) {
                $gateway = $commandGateway;
            } elseif ((int)$gateway->id !== (int)$commandGateway->id) {
                throw new Exception('Relay batch commands must target the same edge gateway');
            }

            $resolution = $this->resolveRelayExecutionPlan($commandGateway, $binding, $logicalRelayId);
            if ($requireFastLocalPath) {
                $resolution = $this->forceLocalRelayExecutionPlan($resolution);
            }
            if (($resolution['execution_path'] ?? 'local') === 'cloud') {
                throw new Exception('Cloud Shelly transport does not support asynchronous relay batches');
            }

            $command = [
                'target' => $target,
                'relayId' => $logicalRelayId,
                'relay_id' => $logicalRelayId,
                'deviceId' => $binding['device_id'],
                'device_id' => $binding['device_id'],
                'localIp' => $binding['local_ip'],
                'local_ip' => $binding['local_ip'],
                'channel' => (int)$binding['channel'],
            ];
            if (isset($request['lane_id']) || isset($request['laneId'])) {
                $command['lane_id'] = (int)($request['lane_id'] ?? $request['laneId']);
                $command['laneId'] = $command['lane_id'];
            }

            $deviceGeneration = $this->resolveRelayBindingDeviceGeneration($binding);
            if ($deviceGeneration !== null) {
                $command['deviceGeneration'] = $deviceGeneration;
                $command['device_generation'] = $deviceGeneration;
            }

            if ($commandType === self::COMMAND_BATCH_SET_RELAY_STATE) {
                $command['on'] = (bool)($request['on'] ?? true);
                $rawToggleAfter = $request['toggle_after'] ?? $request['toggleAfter'] ?? $request['timer'] ?? null;
                $toggleAfter = $rawToggleAfter === null || $rawToggleAfter === ''
                    ? null
                    : $this->normalizeRelayToggleAfter((int)$rawToggleAfter);
                if ($toggleAfter !== null) {
                    $command['toggleAfter'] = $toggleAfter;
                    $command['toggle_after'] = $toggleAfter;
                }
            }

            $commands[] = $command;
        }

        if ($gateway === null) {
            throw new Exception('Unable to resolve edge gateway for relay batch');
        }

        $batchDedupeKey = $this->buildRelayBatchDedupeKey($departmentId, $commandType, $commands, $userId);
        $existingBatch = $this->findRecentRelayBatchByDedupeKey((int)$gateway->id, $batchDedupeKey, self::RELAY_BATCH_DEDUPE_SECONDS);
        if ($existingBatch !== null) {
            $existingBatch['deduplicated'] = true;
            $existingBatch['dedupe_window_seconds'] = self::RELAY_BATCH_DEDUPE_SECONDS;
            return $existingBatch;
        }

        foreach ($commands as $command) {
            $this->enforceRelayCommandRateLimit(
                (int)$gateway->id,
                $departmentId,
                $commandType === self::COMMAND_BATCH_RELAY_STATUS ? 'GET_RELAY_STATUS' : 'SET_RELAY_STATE',
                (string)$command['target'],
                $userId
            );
        }

        foreach ($commands as $command) {
            $isGatePulse = $commandType === self::COMMAND_BATCH_SET_RELAY_STATE
                && in_array((string)$command['target'], ['ENTRANCE', 'EXIT'], true);
            $job = $this->createCommandJob(
                (int)$gateway->id,
                $commandType === self::COMMAND_BATCH_RELAY_STATUS ? 'GET_RELAY_STATUS' : 'SET_RELAY_STATE',
                $command,
                $userId,
                [
                    'department_id' => $departmentId,
                    'batch_id' => $batchId,
                    'batch_dedupe_key' => $batchDedupeKey,
                    'idempotency_key' => $batchId . ':' . (string)$command['target'],
                    'target' => (string)$command['target'],
                    'physical_command' => true,
                    'preferred_channel' => self::DELIVERY_CHANNEL_API,
                    'require_fast_path' => false,
                    'no_auto_retry' => $isGatePulse,
                ]
            );
            $jobs[] = $job;

            if ($commandType === self::COMMAND_BATCH_SET_RELAY_STATE) {
                $this->upsertExpectedRelayStateFromCommand(
                    (int)$gateway->id,
                    $departmentId,
                    $command,
                    (int)$job->id
                );
            }
        }

        edge_gateway_view_cache::syncGateway($this->getGateway((int)$gateway->id));

        return [
            'batch_id' => $batchId,
            'status' => 'PENDING',
            'gateway_id' => (int)$gateway->id,
            'deduplicated' => false,
            'dedupe_window_seconds' => self::RELAY_BATCH_DEDUPE_SECONDS,
            'jobs' => array_map(static fn(edge_gateway_command_jobs_o $job): array => $job->asArray(), $jobs),
            'items' => array_map(static fn(array $command, edge_gateway_command_jobs_o $job): array => [
                'target' => (string)$command['target'],
                'relay_id' => (string)$command['relayId'],
                'job_id' => (int)$job->id,
                'status' => 'PENDING',
            ], $commands, $jobs),
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $commands
     */
    private function buildRelayBatchDedupeKey(int $departmentId, string $commandType, array $commands, ?int $userId): string
    {
        $normalizedCommands = array_map(static function (array $command): array {
            return [
                'target' => (string)($command['target'] ?? ''),
                'relay_id' => (string)($command['relay_id'] ?? $command['relayId'] ?? ''),
                'device_id' => (string)($command['device_id'] ?? $command['deviceId'] ?? ''),
                'channel' => (int)($command['channel'] ?? 0),
                'on' => array_key_exists('on', $command) ? (bool)$command['on'] : null,
                'toggle_after' => isset($command['toggle_after']) ? (int)$command['toggle_after'] : null,
            ];
        }, $commands);

        usort($normalizedCommands, static fn(array $left, array $right): int => ($left['target'] <=> $right['target']) ?: ($left['relay_id'] <=> $right['relay_id']));

        return hash('sha256', json_encode([
            'department_id' => $departmentId,
            'command_type' => $commandType,
            'requested_by' => $userId ?? 0,
            'commands' => $normalizedCommands,
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '');
    }

    /**
     * @return array<string,mixed>|null
     * @throws Exception
     */
    private function findRecentRelayBatchByDedupeKey(int $gatewayId, string $dedupeKey, int $windowSeconds): ?array
    {
        if ($gatewayId <= 0 || $dedupeKey === '') {
            return null;
        }

        $statement = db::getPDO()->prepare(
            "SELECT JSON_UNQUOTE(JSON_EXTRACT(delivery_json, '$.batch_id')) AS batch_id
             FROM edge_gateway_command_jobs
             WHERE gateway_id = :gateway_id
               AND deleted_at IS NULL
               AND requested_at >= :cutoff
               AND JSON_UNQUOTE(JSON_EXTRACT(delivery_json, '$.batch_dedupe_key')) = :dedupe_key
             ORDER BY requested_at DESC, id DESC
             LIMIT 1"
        );
        $statement->execute([
            ':gateway_id' => $gatewayId,
            ':cutoff' => $this->formatDateTime(time() - max(1, $windowSeconds)),
            ':dedupe_key' => $dedupeKey,
        ]);

        $row = $statement->fetch();
        $batchId = is_array($row) ? trim((string)($row['batch_id'] ?? '')) : '';
        return $batchId === '' ? null : $this->relayBatchStatus($batchId);
    }

    /**
     * @throws Exception
     */
    private function enforceRelayCommandRateLimit(
        int $gatewayId,
        int $departmentId,
        string $commandType,
        string $target,
        ?int $userId
    ): void {
        $target = strtoupper(trim($target));
        $isGate = in_array($target, ['ENTRANCE', 'EXIT'], true);
        $limit = $commandType === 'GET_RELAY_STATUS'
            ? self::RELAY_STATUS_RATE_LIMIT_PER_MINUTE
            : ($isGate ? self::RELAY_GATE_RATE_LIMIT_PER_MINUTE : self::RELAY_SET_RATE_LIMIT_PER_MINUTE);

        $clauses = [
            'gateway_id = :gateway_id',
            'deleted_at IS NULL',
            'requested_at >= :cutoff',
            'command_type = :command_type',
            "JSON_UNQUOTE(JSON_EXTRACT(delivery_json, '$.department_id')) = :department_id",
        ];
        $params = [
            ':gateway_id' => $gatewayId,
            ':cutoff' => $this->formatDateTime(time() - 60),
            ':command_type' => $commandType,
            ':department_id' => (string)$departmentId,
        ];

        if ($userId === null) {
            $clauses[] = 'requested_by IS NULL';
        } else {
            $clauses[] = 'requested_by = :requested_by';
            $params[':requested_by'] = $userId;
        }

        if ($isGate) {
            $clauses[] = "JSON_UNQUOTE(JSON_EXTRACT(delivery_json, '$.target')) = :target";
            $params[':target'] = $target;
        }

        $statement = db::getPDO()->prepare(
            'SELECT COUNT(*) AS c
             FROM edge_gateway_command_jobs
             WHERE ' . implode(' AND ', $clauses)
        );
        $statement->execute($params);
        $row = $statement->fetch();
        $count = isset($row['c']) ? (int)$row['c'] : 0;

        if ($count >= $limit) {
            throw new Exception(sprintf(
                'Relay command rate limit exceeded for %s. Try again shortly.',
                $isGate ? strtolower($target) . ' gate' : strtolower(str_replace('_', ' ', $commandType))
            ));
        }
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function listBindings(int $gatewayId): array
    {
        return $this->listRecentObjects(new edge_gateway_relay_bindings_o(), [
            'gateway_id' => $gatewayId,
            'deleted_at' => null,
        ], 100);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function listInventory(int $gatewayId): array
    {
        return $this->listRecentObjects(new edge_gateway_device_inventory_o(), [
            'gateway_id' => $gatewayId,
            'deleted_at' => null,
        ], 100);
    }

    public function buildInstallCommand(string $plainToken): string
    {
        return 'curl -fsSL "' . $this->buildInstallScriptUrl($plainToken) . '" | sudo bash';
    }

    public function buildInstallTokenVerifyUrl(string $plainToken): string
    {
        return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/verify?token=' . urlencode($plainToken);
    }

    public function buildInstallScriptUrl(string $plainToken): string
    {
        return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install.sh?token=' . urlencode($plainToken);
    }

    public function buildInstallScript(string $plainToken): string
    {
        $configJson = json_encode([
            'apiUrl' => $this->getApiBaseUrl(),
            'brokerUrl' => $this->buildBrokerPublicUrl(),
            'installToken' => $plainToken,
            'gatewayId' => null,
            'agentToken' => null,
            'installDir' => self::DEFAULT_INSTALL_DIR,
            'runtimeDir' => self::DEFAULT_RUNTIME_DIR,
            'runtimeMode' => 'compose',
            'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME,
            'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME,
            'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE,
            'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME,
            'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME,
            'lanWorkerArtifactName' => self::DEFAULT_LAN_WORKER_ARTIFACT,
            'autoUpdaterArtifactName' => self::DEFAULT_AUTO_UPDATER_ARTIFACT,
            'edgeAgentDockerfileName' => self::DEFAULT_EDGE_AGENT_DOCKERFILE,
            'lanWorkerDockerfileName' => self::DEFAULT_LAN_WORKER_DOCKERFILE,
            'autoUpdaterDockerfileName' => self::DEFAULT_AUTO_UPDATER_DOCKERFILE,
            'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH,
            'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL,
            'updateWindow' => self::configuredDefaultUpdateWindow(),
            'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE,
            'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE,
            'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE,
            'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE,
            'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE,
            'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE,
            'heartbeatIntervalSeconds' => 15,
            'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
            'installedVersion' => self::DEFAULT_INSTALL_VERSION,
        ], JSON_UNESCAPED_SLASHES);

        $script = <<<'BASH'
#!/usr/bin/env bash
set -Eeuo pipefail
INSTALL_DIR=/opt/truckwash-edge-agent
RUNTIME_DIR="$INSTALL_DIR/runtime"
CONFIG_PATH="$INSTALL_DIR/config.json"
CONFIG_TEMPLATE_PATH="$INSTALL_DIR/config.template.json"
HEARTBEAT_MARKER_PATH="$RUNTIME_DIR/last-heartbeat-ok.txt"
STACK_SERVICE_PATH="/etc/systemd/system/truckwash-edge-gateway-stack.service"
LEGACY_SERVICE_PATH="/etc/systemd/system/truckwash-edge-agent.service"
INSTALL_TOKEN="__INSTALL_TOKEN__"
INSTALL_STATUS_URL="__STATUS_URL__"
CURRENT_STEP="Preparing installer"
CURRENT_STEP_CODE="PENDING"
CURRENT_METHOD=""
CURRENT_URL=""
INSTALL_STARTED_AT="$(date +%s)"
DIAGNOSTIC_NAMES=()
DIAGNOSTIC_OUTPUTS=()
json_escape() {
  local value="${1:-}"
  value="${value//\\/\\\\}"
  value="${value//\"/\\\"}"
  value="${value//$'\n'/\\n}"
  value="${value//$'\r'/\\r}"
  value="${value//$'\t'/\\t}"
  printf '%s' "$value"
}
trim_diagnostic_output() {
  printf '%s' "${1:-}" | awk 'NR <= 80 { print } NR == 81 { print "...<trimmed>"; exit }' | head -c 4000
}
append_diagnostic() {
  local name="$1"
  local output="$2"
  if [ -z "$name" ] || [ -z "$output" ]; then
    return 0
  fi
  DIAGNOSTIC_NAMES+=("$name")
  DIAGNOSTIC_OUTPUTS+=("$output")
  if [ "${#DIAGNOSTIC_NAMES[@]}" -gt 6 ]; then
    DIAGNOSTIC_NAMES=("${DIAGNOSTIC_NAMES[@]: -6}")
    DIAGNOSTIC_OUTPUTS=("${DIAGNOSTIC_OUTPUTS[@]: -6}")
  fi
}
emit_diagnostic_json() {
  local json="["
  local index
  for index in "${!DIAGNOSTIC_NAMES[@]}"; do
    if [ "$index" -gt 0 ]; then
      json="${json},"
    fi
    json="${json}{\"name\":\"$(json_escape "${DIAGNOSTIC_NAMES[$index]}")\",\"output\":\"$(json_escape "${DIAGNOSTIC_OUTPUTS[$index]}")\"}"
  done
  json="${json}]"
  printf '%s' "$json"
}
print_collected_diagnostics() {
  local index
  for index in "${!DIAGNOSTIC_NAMES[@]}"; do
    if [ -z "${DIAGNOSTIC_OUTPUTS[$index]:-}" ]; then
      continue
    fi
    log_error "Diagnostic: ${DIAGNOSTIC_NAMES[$index]}"
    printf '%s\n' "${DIAGNOSTIC_OUTPUTS[$index]}" | sed 's/^/[truckwash-edge-agent]   /' >&2
  done
}
capture_command_diagnostic() {
  local name="$1"
  shift
  local output=""
  set +e
  output="$("$@" 2>&1)"
  set -e
  output="$(trim_diagnostic_output "$output")"
  if [ -n "$output" ]; then
    append_diagnostic "$name" "$output"
  fi
}
report_install_status() {
  local status="$1"
  local step="$2"
  local message="$3"
  local diagnostics_json="${4:-[]}"
  local gateway_id="${5:-}"
  local payload

  payload="{\"token\":\"$(json_escape "$INSTALL_TOKEN")\",\"status\":\"$(json_escape "$status")\",\"step\":\"$(json_escape "$step")\",\"message\":\"$(json_escape "$message")\",\"diagnostics\":${diagnostics_json:-[]}"
  if [ -n "${gateway_id:-}" ] && [ "$gateway_id" -gt 0 ] 2>/dev/null; then
    payload="${payload},\"gateway_id\":${gateway_id}"
  fi
  payload="${payload}}"

  set +e
  curl -sS -X POST -H "Content-Type: application/json" --data-binary "$payload" "$INSTALL_STATUS_URL" >/dev/null 2>&1
  set -e
}
begin_install_phase() {
  CURRENT_STEP_CODE="$1"
  CURRENT_STEP="$2"
  CURRENT_METHOD=""
  CURRENT_URL=""
  report_install_status "RUNNING" "$CURRENT_STEP_CODE" "$CURRENT_STEP"
}
log_info() {
  printf '[truckwash-edge-agent] %s\n' "$1"
}
log_error() {
  printf '[truckwash-edge-agent] ERROR: %s\n' "$1" >&2
}
collect_install_diagnostics() {
  DIAGNOSTIC_NAMES=()
  DIAGNOSTIC_OUTPUTS=()

  if [ -n "${CURRENT_METHOD:-}" ] && [ -n "${CURRENT_URL:-}" ]; then
    append_diagnostic "Last request" "${CURRENT_METHOD} ${CURRENT_URL}"
  fi

  if command -v systemctl >/dev/null 2>&1; then
    capture_command_diagnostic "systemctl status" systemctl status --no-pager truckwash-edge-gateway-stack.service
  fi
  if command -v journalctl >/dev/null 2>&1; then
    capture_command_diagnostic "journalctl" journalctl -a -u truckwash-edge-gateway-stack.service -n 80 --no-pager
  fi
  if command -v docker >/dev/null 2>&1; then
    capture_command_diagnostic "docker ps -a" docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}'
    if [ -f "$INSTALL_DIR/docker-compose.gateway.yml" ]; then
      if docker compose version >/dev/null 2>&1; then
        capture_command_diagnostic "docker compose ps" docker compose -f "$INSTALL_DIR/docker-compose.gateway.yml" ps -a --no-trunc
        capture_command_diagnostic "docker compose logs" docker compose -f "$INSTALL_DIR/docker-compose.gateway.yml" logs --no-color --no-log-prefix --tail=120
      elif command -v docker-compose >/dev/null 2>&1; then
        capture_command_diagnostic "docker-compose ps" docker-compose -f "$INSTALL_DIR/docker-compose.gateway.yml" ps -a
        capture_command_diagnostic "docker-compose logs" docker-compose -f "$INSTALL_DIR/docker-compose.gateway.yml" logs --no-color --tail=120
      fi
    fi
  fi

  return 0
}
on_error() {
  local exit_code=$?
  local failure_message="Installer failed during step ${CURRENT_STEP_CODE:-FAILED}: ${CURRENT_STEP:-unknown}"
  local diagnostics_json
  local gateway_id
  log_error "$failure_message"
  if [ -n "${CURRENT_METHOD:-}" ] && [ -n "${CURRENT_URL:-}" ]; then
    log_error "Last request: ${CURRENT_METHOD} ${CURRENT_URL}"
  fi
  collect_install_diagnostics
  diagnostics_json="$(emit_diagnostic_json)"
  print_collected_diagnostics
  gateway_id="$(read_config_value "$CONFIG_PATH" gatewayId 2>/dev/null || true)"
  report_install_status "FAILED" "FAILED" "$failure_message" "$diagnostics_json" "$gateway_id"
  exit "$exit_code"
}
trap on_error ERR
run_step() {
  local description="$1"
  shift
  CURRENT_STEP="$description"
  CURRENT_METHOD=""
  CURRENT_URL=""
  log_info "$description"
  "$@"
}
fetch_http() {
  local description="$1"
  local url="$2"
  local output_path="${3:-}"
  local body_path="$output_path"
  local headers_path
  local status=""
  local curl_exit=0
  local preview=""
  local cleanup_body=0

  if [ -z "$body_path" ]; then
    body_path="$(mktemp)"
    cleanup_body=1
  fi
  headers_path="$(mktemp)"

  CURRENT_STEP="$description"
  CURRENT_METHOD="GET"
  CURRENT_URL="$url"
  log_info "${description}: GET ${url}"

  set +e
  status="$(curl -sS -L -D "$headers_path" -o "$body_path" -w '%{http_code}' "$url")"
  curl_exit=$?
  set -e

  if [ "$curl_exit" -ne 0 ]; then
    log_error "${description} request failed before a successful HTTP response was received."
    log_error "Request: GET ${url}"
    log_error "curl exit code: ${curl_exit}"
    if [ -s "$headers_path" ]; then
      log_error "Response headers:"
      sed 's/^/[truckwash-edge-agent]   /' "$headers_path" >&2
    fi
    if [ -s "$body_path" ]; then
      preview="$(head -c 400 "$body_path" || true)"
      if [ -n "$preview" ]; then
        log_error "Response body preview (first 400 bytes):"
        printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent]   /' >&2
      fi
    fi
    [ "$cleanup_body" -eq 1 ] && rm -f "$body_path"
    rm -f "$headers_path"
    return "$curl_exit"
  fi

  if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
    log_error "${description} returned HTTP ${status}."
    log_error "Request: GET ${url}"
    if [ -s "$headers_path" ]; then
      log_error "Response headers:"
      sed 's/^/[truckwash-edge-agent]   /' "$headers_path" >&2
    fi
    if [ -s "$body_path" ]; then
      preview="$(head -c 400 "$body_path" || true)"
      if [ -n "$preview" ]; then
        log_error "Response body preview (first 400 bytes):"
        printf '%s\n' "$preview" | sed 's/^/[truckwash-edge-agent]   /' >&2
      fi
    fi
    [ "$cleanup_body" -eq 1 ] && rm -f "$body_path"
    rm -f "$headers_path"
    return 1
  fi

  [ "$cleanup_body" -eq 1 ] && rm -f "$body_path"
  rm -f "$headers_path"
}
verify_manifest_artifact() {
  local manifest_path="$1"
  local artifact_name="$2"
  local artifact_path="$3"
  log_info "Verifying ${artifact_name} checksum"
  php -r '
    $manifestPath = $argv[1];
    $artifactName = $argv[2];
    $artifactPath = $argv[3];
    $manifest = json_decode((string)file_get_contents($manifestPath), true);
    if (!is_array($manifest)) {
        fwrite(STDERR, "Invalid artifact manifest: " . $manifestPath . PHP_EOL);
        exit(1);
    }
    $expected = null;
    foreach ((array)($manifest["artifacts"] ?? []) as $artifact) {
        if (is_array($artifact) && ($artifact["name"] ?? null) === $artifactName) {
            $expected = (string)($artifact["sha256"] ?? "");
            break;
        }
    }
    if ($expected === null || $expected === "") {
        fwrite(STDERR, "Artifact missing from manifest: " . $artifactName . PHP_EOL);
        exit(1);
    }
    if (!is_file($artifactPath)) {
        fwrite(STDERR, "Downloaded artifact is missing: " . $artifactPath . PHP_EOL);
        exit(1);
    }
    $actual = hash_file("sha256", $artifactPath);
    if ($actual === false || !hash_equals($expected, $actual)) {
        fwrite(STDERR, "Artifact checksum mismatch for " . $artifactName . PHP_EOL);
        exit(1);
    }
  ' "$manifest_path" "$artifact_name" "$artifact_path"
}
remove_docker_containers_matching_filter() {
  local filter="$1"
  local ids
  ids="$(docker ps -aq --filter "$filter" 2>/dev/null || true)"
  if [ -n "$ids" ]; then
    printf '%s\n' "$ids" | xargs -r docker rm -f >/dev/null 2>&1 || true
  fi
}
remove_edge_gateway_containers() {
  local name
  local project_name
  for name in \
    truckwash-edge-agent \
    truckwash-lan-worker \
    truckwash-auto-updater \
    truckwash-redis \
    truckwash-mariadb \
    truckwash-minio; do
    remove_docker_containers_matching_filter "name=${name}"
  done

  project_name="$(read_config_value "$CONFIG_PATH" composeProjectName 2>/dev/null || true)"
  if [ -z "$project_name" ]; then
    project_name="truckwash-edge-gateway"
  fi
  remove_docker_containers_matching_filter "label=com.docker.compose.project=${project_name}"
  remove_docker_containers_matching_filter "label=com.docker.compose.project.working_dir=${INSTALL_DIR}"
}
cleanup_existing_installation() {
  if [ "$INSTALL_DIR" != "/opt/truckwash-edge-agent" ]; then
    echo "Refusing to remove unexpected install directory: $INSTALL_DIR" >&2
    return 1
  fi

  systemctl stop truckwash-edge-gateway-stack.service >/dev/null 2>&1 || true
  systemctl stop truckwash-edge-agent.service >/dev/null 2>&1 || true

  if [ -x "$INSTALL_DIR/gateway-launcher.sh" ]; then
    TRUCKWASH_INSTALL_DIR="$INSTALL_DIR" "$INSTALL_DIR/gateway-launcher.sh" down >/dev/null 2>&1 || true
  elif [ -f "$INSTALL_DIR/docker-compose.gateway.yml" ]; then
    if docker compose version >/dev/null 2>&1; then
      docker compose -f "$INSTALL_DIR/docker-compose.gateway.yml" down >/dev/null 2>&1 || true
    elif command -v docker-compose >/dev/null 2>&1; then
      docker-compose -f "$INSTALL_DIR/docker-compose.gateway.yml" down >/dev/null 2>&1 || true
    fi
  fi

  if command -v docker >/dev/null 2>&1; then
    remove_edge_gateway_containers
  fi

  systemctl disable truckwash-edge-gateway-stack.service >/dev/null 2>&1 || true
  systemctl disable truckwash-edge-agent.service >/dev/null 2>&1 || true
  rm -f "$STACK_SERVICE_PATH" "$LEGACY_SERVICE_PATH"
  rm -rf "$INSTALL_DIR"
  systemctl daemon-reload >/dev/null 2>&1 || true
  systemctl reset-failed truckwash-edge-gateway-stack.service >/dev/null 2>&1 || true
  systemctl reset-failed truckwash-edge-agent.service >/dev/null 2>&1 || true
  return 0
}
config_has_claimed_gateway() {
  local config_path="$1"
  php -r '
    $path = $argv[1];
    if (!is_file($path)) {
        exit(1);
    }
    $decoded = json_decode((string)file_get_contents($path), true);
    if (!is_array($decoded)) {
        exit(1);
    }
    $gatewayId = isset($decoded["gatewayId"]) ? (int)$decoded["gatewayId"] : 0;
    $agentToken = isset($decoded["agentToken"]) ? trim((string)$decoded["agentToken"]) : "";
    exit($gatewayId > 0 && $agentToken !== "" ? 0 : 1);
  ' "$config_path"
}
read_config_value() {
  local config_path="$1"
  local key="$2"
  php -r '
    $path = $argv[1];
    $key = $argv[2];
    if (!is_file($path)) {
        exit(0);
    }
    $decoded = json_decode((string)file_get_contents($path), true);
    if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null) {
        exit(0);
    }
    $value = $decoded[$key];
    if (is_array($value) || is_object($value)) {
        echo json_encode($value, JSON_UNESCAPED_SLASHES);
        exit(0);
    }
    echo (string)$value;
  ' "$config_path" "$key"
}
heartbeat_marker_is_fresh() {
  local heartbeat_path="$1"
  local minimum_epoch="$2"
  if [ ! -f "$heartbeat_path" ]; then
    return 1
  fi
  local modified_epoch
  modified_epoch="$(stat -c %Y "$heartbeat_path" 2>/dev/null || echo 0)"
  [ "${modified_epoch:-0}" -ge "$minimum_epoch" ]
}
print_service_diagnostics() {
  log_error "truckwash-edge-gateway-stack.service did not complete installation verification."
  log_error "systemctl status --no-pager truckwash-edge-gateway-stack.service"
  systemctl status --no-pager truckwash-edge-gateway-stack.service || true
  log_error "journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager"
  journalctl -u truckwash-edge-gateway-stack.service -n 60 --no-pager || true
  log_error "docker ps --format '{{.Names}} {{.Status}}'"
  docker ps --format '{{.Names}} {{.Status}}' || true
}
resolve_compose_command() {
  if docker compose version >/dev/null 2>&1; then
    echo "docker compose"
    return 0
  fi
  if command -v docker-compose >/dev/null 2>&1; then
    echo "docker-compose"
    return 0
  fi
  return 1
}
apt_package_exists() {
  local package_name="$1"
  apt-cache show "$package_name" 2>/dev/null | grep -q '^Package: '
}
install_compose_runtime() {
  if resolve_compose_command >/dev/null 2>&1; then
    return 0
  fi

  if apt_package_exists docker-compose-plugin; then
    log_info "Installing Docker Compose package docker-compose-plugin"
    apt-get install -y docker-compose-plugin
  elif apt_package_exists docker-compose; then
    log_info "Installing Docker Compose package docker-compose"
    apt-get install -y docker-compose
  else
    if apt-get install -y docker-compose-plugin; then
      :
    elif apt-get install -y docker-compose; then
      :
    else
      echo "Unable to install Docker Compose using docker-compose-plugin or docker-compose." >&2
      return 1
    fi
  fi

  if ! resolve_compose_command >/dev/null 2>&1; then
    echo "Docker Compose command is unavailable after installation." >&2
    return 1
  fi
}
wait_for_gateway_claim() {
  local config_path="$1"
  local heartbeat_path="$2"
  local minimum_epoch="$3"
  local timeout_seconds="${4:-30}"
  local elapsed=0
  while [ "$elapsed" -lt "$timeout_seconds" ]; do
    if config_has_claimed_gateway "$config_path" && heartbeat_marker_is_fresh "$heartbeat_path" "$minimum_epoch"; then
      return 0
    fi
    sleep 1
    elapsed=$((elapsed + 1))
  done
  log_error "Gateway claim did not complete within ${timeout_seconds}s."
  print_service_diagnostics
  return 1
}
begin_install_phase "VERIFY_TOKEN" "Verifying install token"
fetch_http "Verify install token" "__VERIFY_URL__"
begin_install_phase "REMOVE_EXISTING_INSTALL" "Removing existing edge gateway installation"
run_step "Removing existing edge gateway installation" cleanup_existing_installation
begin_install_phase "INSTALL_PACKAGES" "Installing runtime dependencies"
run_step "Creating install directory" mkdir -p "$INSTALL_DIR" "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
export DEBIAN_FRONTEND=noninteractive
run_step "Updating package lists" apt-get update
run_step "Installing base packages" apt-get install -y curl ca-certificates docker.io php-cli php-curl php-mbstring php-sqlite3
run_step "Installing Docker Compose runtime" install_compose_runtime
begin_install_phase "DOWNLOAD_ARTIFACTS" "Downloading edge gateway artifacts"
fetch_http "Download artifact manifest" "__MANIFEST_URL__" "$INSTALL_DIR/manifest.json"
fetch_http "Download PHP edge agent" "__AGENT_URL__" "$INSTALL_DIR/agent.php"
fetch_http "Download LAN worker" "__WORKER_URL__" "$INSTALL_DIR/lan-worker.php"
fetch_http "Download auto-updater" "__AUTO_UPDATER_URL__" "$INSTALL_DIR/auto-updater.php"
fetch_http "Download compose stack" "__COMPOSE_URL__" "$INSTALL_DIR/docker-compose.gateway.yml"
fetch_http "Download edge-agent Dockerfile" "__EDGE_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.edge-agent"
fetch_http "Download lan-worker Dockerfile" "__WORKER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.lan-worker"
fetch_http "Download auto-updater Dockerfile" "__AUTO_UPDATER_DOCKERFILE_URL__" "$INSTALL_DIR/Dockerfile.auto-updater"
fetch_http "Download gateway launcher" "__LAUNCHER_URL__" "$INSTALL_DIR/gateway-launcher.sh"
fetch_http "Download compose stack service unit" "__STACK_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"
fetch_http "Download compatibility service unit" "__LEGACY_SERVICE_URL__" "$INSTALL_DIR/truckwash-edge-agent.service"
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
run_step "Verifying PHP edge agent" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "agent.php" "$INSTALL_DIR/agent.php"
run_step "Verifying LAN worker" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "lan-worker.php" "$INSTALL_DIR/lan-worker.php"
run_step "Verifying auto-updater" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "auto-updater.php" "$INSTALL_DIR/auto-updater.php"
run_step "Verifying compose stack" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "docker-compose.gateway.yml" "$INSTALL_DIR/docker-compose.gateway.yml"
run_step "Verifying edge-agent Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.edge-agent" "$INSTALL_DIR/Dockerfile.edge-agent"
run_step "Verifying lan-worker Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.lan-worker" "$INSTALL_DIR/Dockerfile.lan-worker"
run_step "Verifying auto-updater Dockerfile" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "Dockerfile.auto-updater" "$INSTALL_DIR/Dockerfile.auto-updater"
run_step "Verifying gateway launcher" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "gateway-launcher.sh" "$INSTALL_DIR/gateway-launcher.sh"
run_step "Verifying compose stack service unit" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "truckwash-edge-gateway-stack.service" "$INSTALL_DIR/truckwash-edge-gateway-stack.service"
run_step "Verifying compatibility service unit" verify_manifest_artifact "$INSTALL_DIR/manifest.json" "truckwash-edge-agent.service" "$INSTALL_DIR/truckwash-edge-agent.service"
begin_install_phase "WRITE_CONFIG" "Writing gateway configuration"
cat > "$CONFIG_TEMPLATE_PATH" <<'EOF_JSON'
__CONFIG_JSON__
EOF_JSON
run_step "Writing fresh agent config" cp "$CONFIG_TEMPLATE_PATH" "$CONFIG_PATH"
rm -f "$CONFIG_TEMPLATE_PATH"
begin_install_phase "START_STACK" "Starting edge gateway stack"
run_step "Installing systemd stack definition" install -m 0644 "$INSTALL_DIR/truckwash-edge-gateway-stack.service" "$STACK_SERVICE_PATH"
run_step "Setting executable permissions" chmod 0755 "$INSTALL_DIR/agent.php" "$INSTALL_DIR/lan-worker.php" "$INSTALL_DIR/auto-updater.php" "$INSTALL_DIR/gateway-launcher.sh"
run_step "Ensuring Docker is enabled" systemctl enable docker
run_step "Starting Docker" systemctl restart docker
run_step "Checking Docker Compose availability" resolve_compose_command >/dev/null
run_step "Reloading systemd" systemctl daemon-reload
run_step "Enabling truckwash-edge-gateway-stack.service" systemctl enable truckwash-edge-gateway-stack.service
run_step "Restarting truckwash-edge-gateway-stack.service" systemctl restart truckwash-edge-gateway-stack.service
run_step "Verifying truckwash-edge-gateway-stack.service is active" systemctl is-active --quiet truckwash-edge-gateway-stack.service
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
run_step "Waiting for gateway claim" wait_for_gateway_claim "$CONFIG_PATH" "$HEARTBEAT_MARKER_PATH" "$INSTALL_STARTED_AT" 180
claimed_gateway_id="$(read_config_value "$CONFIG_PATH" gatewayId)"
report_install_status "CLAIMED" "CLAIMED" "Gateway claim completed successfully." "[]" "$claimed_gateway_id"
log_info "Gateway claim completed for gateway ${claimed_gateway_id}."
echo 'TruckWash edge gateway stack installed.'
BASH;

        return strtr($script, [
            '__INSTALL_TOKEN__' => $plainToken,
            '__VERIFY_URL__' => $this->buildInstallTokenVerifyUrl($plainToken),
            '__STATUS_URL__' => rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/install-token/status',
            '__MANIFEST_URL__' => $this->buildAgentArtifactUrl('manifest.json'),
            '__AGENT_URL__' => $this->buildAgentArtifactUrl('agent.php'),
            '__WORKER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT),
            '__AUTO_UPDATER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT),
            '__COMPOSE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_COMPOSE_STACK_FILE),
            '__EDGE_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_EDGE_AGENT_DOCKERFILE),
            '__WORKER_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_DOCKERFILE),
            '__AUTO_UPDATER_DOCKERFILE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_DOCKERFILE),
            '__LAUNCHER_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_LAUNCHER_SCRIPT_NAME),
            '__STACK_SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_STACK_SERVICE_NAME),
            '__LEGACY_SERVICE_URL__' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME),
            '__CONFIG_JSON__' => (string)$configJson,
        ]);
    }

    /**
     * @throws Exception
     */
    public function buildUpdateOperationRequest(string $targetVersion, string $releaseChannel): array
    {
        return array_merge(
            $this->buildUpdateCommandPayload($targetVersion, $releaseChannel),
            [
                'target_version' => $targetVersion,
                'release_channel' => $releaseChannel,
            ]
        );
    }

    /**
     * @throws Exception
     */
    public function rotateGatewayCredentials(int $gatewayId, ?int $userId = null): array
    {
        $gateway = $this->requireGateway($gatewayId);
        $newToken = bin2hex(random_bytes(32));
        $gateway->agent_token_hash->set($this->hashToken($newToken));

        $metadata = (array)($gateway->metadata_json->value() ?? []);
        $metadata['credentials_rotated_at'] = $this->now();
        $metadata['credential_rotation_requested_by'] = $userId;
        $gateway->metadata_json->set($metadata);

        $payload = [
            'apiUrl' => $this->getApiBaseUrl(),
            'brokerUrl' => $this->buildBrokerPublicUrl(),
            'gatewayId' => (int)$gateway->id,
            'agentToken' => $newToken,
            'installDir' => self::DEFAULT_INSTALL_DIR,
            'runtimeDir' => self::DEFAULT_RUNTIME_DIR,
            'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME,
            'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME,
            'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE,
            'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME,
            'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME,
            'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH,
            'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL,
            'updateWindow' => (string)($metadata['update_window'] ?? self::configuredDefaultUpdateWindow()),
            'runtimeMode' => (string)($metadata['runtime_mode'] ?? 'compose'),
            'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE,
            'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE,
            'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE,
            'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE,
            'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE,
            'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE,
            'heartbeatIntervalSeconds' => 15,
            'operationPollTimeoutSeconds' => self::COMMAND_POLL_TIMEOUT_SECONDS,
            'installedVersion' => $gateway->installed_version->value() === null ? null : (string)$gateway->installed_version->value(),
        ];

        $this->writeAudit(
            (int)$gateway->id,
            (int)$gateway->department_id->value(),
            'GATEWAY_CREDENTIALS_ROTATED',
            $userId,
            [
                'service_name' => self::DEFAULT_AGENT_SERVICE_NAME,
                'stack_service_name' => self::DEFAULT_STACK_SERVICE_NAME,
            ]
        );

        edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId));

        return [
            'gateway_id' => (int)$gateway->id,
            'rotated_at' => (string)$metadata['credentials_rotated_at'],
            'agent_token' => $newToken,
            'config' => $payload,
            'config_json' => json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
            'restart_instructions' => [
                'sudo systemctl restart ' . self::DEFAULT_STACK_SERVICE_NAME,
                'sudo systemctl status ' . self::DEFAULT_STACK_SERVICE_NAME . ' --no-pager',
                'cd ' . self::DEFAULT_INSTALL_DIR . ' && sudo ./gateway-launcher.sh reconcile',
            ],
        ];
    }

    public function syncGatewayInventory(int $gatewayId, array $inventory): void
    {
        $this->syncDeviceInventory($gatewayId, $inventory);
    }

    public function logGatewayAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void
    {
        $this->writeAudit($gatewayId, $departmentId, $action, $userId, $context);
    }

    /**
     * @throws Exception
     */
    public function buildGatewayTasksPage(int $gatewayId): array
    {
        $gateway = $this->getGateway($gatewayId);
        $operations = new edge_gateway_operation_service($this);

        return [
            'gateway' => $gateway,
            'active_operation' => $operations->getActiveOperation($gatewayId, true),
            'operations' => $operations->listOperations($gatewayId, 20, true),
            'recent_commands' => $this->listRecentObjects(
                new edge_gateway_command_jobs_o(),
                ['gateway_id' => $gatewayId, 'deleted_at' => null],
                20
            ),
            'recent_operations_summary' => $operations->buildRecentOperationsSummary($gatewayId),
        ];
    }

    /**
     * @throws Exception
     */
    public function buildGatewayLogsPage(int $gatewayId, int $limit = 120): array
    {
        $gateway = $this->getGateway($gatewayId);
        $operations = (new edge_gateway_operation_service($this))->listOperations($gatewayId, 20, true);
        $auditLogs = $this->listRecentObjects(new edge_gateway_audit_logs_o(), ['gateway_id' => $gatewayId], $limit);
        $liveLogs = $this->listRecentObjects(
            new edge_gateway_log_entries_o(),
            ['gateway_id' => $gatewayId],
            $limit
        );
        $shellSessions = $this->listRecentObjects(
            new edge_gateway_shell_sessions_o(),
            ['gateway_id' => $gatewayId, 'deleted_at' => null],
            12
        );

        $relayLogs = [];
        $timeline = [];
        foreach ($auditLogs as $auditLog) {
            $timeline[] = [
                'type' => 'audit',
                'level' => (string)($auditLog['severity'] ?? 'INFO'),
                'message' => (string)($auditLog['action'] ?? 'AUDIT_EVENT'),
                'created_at' => (string)($auditLog['created_at'] ?? ''),
                'entry' => $auditLog,
            ];
        }
        foreach ($liveLogs as $logEntry) {
            $type = strtolower(trim((string)($logEntry['stream'] ?? ''))) === 'relay' ? 'relay' : 'log';
            if ($type === 'relay') {
                $relayLogs[] = $logEntry;
            }
            $timeline[] = [
                'type' => $type,
                'level' => (string)($logEntry['level'] ?? 'INFO'),
                'message' => (string)($logEntry['message'] ?? ''),
                'created_at' => (string)($logEntry['created_at'] ?? ''),
                'entry' => $logEntry,
            ];
        }
        foreach ($operations as $operation) {
            foreach ((array)($operation['events'] ?? []) as $event) {
                if (!is_array($event)) {
                    continue;
                }
                $timeline[] = [
                    'type' => 'operation_event',
                    'level' => (string)($event['level'] ?? 'INFO'),
                    'message' => (string)($event['message'] ?? ''),
                    'created_at' => (string)($event['created_at'] ?? ''),
                    'entry' => array_merge($event, [
                        'operation_id' => $operation['id'] ?? null,
                        'operation_type' => $operation['type'] ?? null,
                    ]),
                ];
            }
        }

        usort(
            $timeline,
            static fn(array $left, array $right): int => strcmp(
                (string)($right['created_at'] ?? ''),
                (string)($left['created_at'] ?? '')
            )
        );

        return [
            'gateway' => $gateway,
            'timeline' => array_slice($timeline, 0, max(20, $limit)),
            'audit_logs' => $auditLogs,
            'log_entries' => $liveLogs,
            'relay_logs' => $relayLogs,
            'shell_sessions' => $shellSessions,
        ];
    }

    /**
     * @throws Exception
     */
    public function buildGatewayStatisticsPage(int $gatewayId): array
    {
        $gateway = $this->getGateway($gatewayId);
        $fleetUsage = $this->buildFleetUsageStatistics((int)$gateway['department_id'], [$gateway]);

        return [
            'gateway' => $gateway,
            'fleet_usage' => $fleetUsage,
            'channel_status' => (array)($gateway['channel_status'] ?? []),
            'transport_health' => (array)($gateway['transport_health'] ?? []),
            'backlog_depth' => (array)($gateway['backlog_depth'] ?? []),
            'container_health' => (array)($gateway['container_health'] ?? []),
            'system_metrics' => (array)($gateway['metadata']['system_metrics'] ?? []),
            'version_drift' => (array)($gateway['version_drift'] ?? []),
        ];
    }

    /**
     * @throws Exception
     */
    public function validateGatewayAgentForBroker(int $gatewayId, string $plainToken): array
    {
        $gateway = $this->authenticateGateway($gatewayId, $plainToken);

        return [
            'id' => (int)$gateway->id,
            'gateway_id' => (int)$gateway->id,
            'department_id' => (int)$gateway->department_id->value(),
            'label' => (string)$gateway->label->value(),
            'broker_url' => $this->buildBrokerPublicUrl(),
        ];
    }

    /**
     * @throws Exception
     */
    public function createBrowserStreamSession(int $gatewayId, ?int $userId, array $scopes = []): array
    {
        $gateway = $this->requireGateway($gatewayId);
        $scopes = array_values(array_unique(array_filter(array_map(
            static fn(mixed $scope): string => strtolower(trim((string)$scope)),
            $scopes
        ))));
        if ($scopes === []) {
            $scopes = ['overview', 'tasks', 'logs', 'statistics'];
        }

        $expiresAt = time() + self::BROWSER_STREAM_TOKEN_TTL_SECONDS;
        $token = $this->buildSignedBrokerToken([
            'session_type' => 'gateway-stream',
            'gateway_id' => (int)$gateway->id,
            'department_id' => (int)$gateway->department_id->value(),
            'user_id' => $userId,
            'scopes' => $scopes,
            'exp' => $expiresAt,
            'iat' => time(),
            'jti' => bin2hex(random_bytes(12)),
        ]);

        return [
            'token' => $token,
            'gateway_id' => (int)$gateway->id,
            'expires_at' => $this->formatDateTime($expiresAt),
            'scopes' => $scopes,
            'broker_url' => $this->buildBrokerPublicUrl(),
            'ws_url' => $this->buildBrokerPublicWebSocketUrl('/ws/browser-gateway-stream'),
        ];
    }

    public function validateBrowserStreamToken(string $token): array
    {
        $payload = $this->parseSignedBrokerToken($token);
        if (($payload['session_type'] ?? null) !== 'gateway-stream') {
            throw new Exception('Invalid gateway stream token');
        }

        return $payload;
    }

    /**
     * @throws Exception
     */
    public function createShellSession(
        int $gatewayId,
        ?int $userId,
        string $reason = '',
        ?int $cols = null,
        ?int $rows = null,
        ?string $cwd = null
    ): array {
        $gateway = $this->requireGateway($gatewayId);
        $readiness = $this->buildShellReadinessDiagnostics($gateway);
        if (empty($readiness['ready'])) {
            throw new edge_gateway_operation_exception(
                (string)$readiness['message'],
                (string)$readiness['reason_code'],
                409,
                $readiness
            );
        }

        $sessionToken = bin2hex(random_bytes(32));
        $expiresAt = $this->formatDateTime(time() + self::SHELL_SESSION_TTL_SECONDS);
        $brokerUrl = $this->buildBrokerPublicUrl();
        $wsUrl = $this->buildBrokerPublicWebSocketUrl('/ws/browser-shell');

        $sessionObject = new edge_gateway_shell_sessions_o();
        $sessionId = $sessionObject->add_object([
            'gateway_id' => (int)$gateway->id,
            'department_id' => (int)$gateway->department_id->value(),
            'actor_user_id' => $userId,
            'session_token_hash' => $this->hashToken($sessionToken),
            'status' => 'PENDING',
            'reason' => trim($reason) !== '' ? trim($reason) : 'Diagnostic shell session',
            'cwd' => $cwd ?: self::DEFAULT_INSTALL_DIR,
            'shell_command' => null,
            'shell_args_json' => [],
            'cols' => $cols,
            'terminal_rows' => $rows,
            'transcript' => null,
            'metadata_json' => [
                'root_dir' => self::DEFAULT_INSTALL_DIR,
                'shell_diagnostics' => $readiness,
                'requested_broker_url' => $brokerUrl,
                'requested_ws_url' => $wsUrl,
                'shortcut_paths' => [
                    self::DEFAULT_INSTALL_DIR,
                    self::DEFAULT_RUNTIME_DIR,
                ],
            ],
            'expires_at' => $expiresAt,
            'approved_at' => $this->now(),
            'opened_at' => null,
            'closed_at' => null,
        ]);

        $session = $sessionObject->select($sessionId)->asArray();
        $this->writeAudit(
            (int)$gateway->id,
            (int)$gateway->department_id->value(),
            'GATEWAY_SHELL_SESSION_CREATED',
            $userId,
            ['shell_session_id' => $sessionId, 'reason' => $session['reason']]
        );

        return [
            'session' => $session,
            'token' => $sessionToken,
            'gateway_id' => (int)$gateway->id,
            'expires_at' => $expiresAt,
            'broker_url' => $brokerUrl,
            'ws_url' => $wsUrl,
            'diagnostics' => $readiness,
        ];
    }

    /**
     * @throws Exception
     */
    public function validateShellSessionToken(string $plainToken): array
    {
        $session = $this->findShellSessionByToken($plainToken);
        $status = strtoupper((string)$session->status->value());
        if (!in_array($status, ['PENDING', 'OPEN'], true)) {
            throw new Exception('Shell session is closed');
        }

        $expiresAt = self::parseApplicationDateTime(
            $session->expires_at->value() === null ? null : (string)$session->expires_at->value()
        );
        if ($expiresAt !== null && $expiresAt <= time()) {
            $session->status->set('EXPIRED');
            $session->closed_at->set($this->now());
            throw new Exception('Shell session expired');
        }

        return $session->asArray();
    }

    /**
     * @throws Exception
     */
    public function markShellSessionOpened(string $plainToken, ?string $connectionId = null): array
    {
        $session = $this->findShellSessionByToken($plainToken);
        if ((string)$session->status->value() !== 'OPEN') {
            $session->status->set('OPEN');
            $session->opened_at->set($this->now());
        }
        if ($connectionId !== null && trim($connectionId) !== '') {
            $session->connection_id->set(trim($connectionId));
        }

        $this->writeAudit(
            (int)$session->gateway_id->value(),
            (int)$session->department_id->value(),
            'GATEWAY_SHELL_SESSION_OPENED',
            $session->actor_user_id->value() === null ? null : (int)$session->actor_user_id->value(),
            ['shell_session_id' => (int)$session->id]
        );

        return $session->asArray();
    }

    /**
     * @throws Exception
     */
    public function closeShellSessionByToken(
        string $plainToken,
        string $transcript = '',
        ?string $reason = null,
        array $details = []
    ): array {
        $session = $this->findShellSessionByToken($plainToken);
        $metadata = (array)($session->metadata_json->value() ?? []);
        $closeDiagnostics = $this->normalizeShellCloseDiagnostics($reason, $details);
        $metadata['close_reason'] = $reason;
        $metadata['close_message'] = $closeDiagnostics['message'] ?? null;
        $metadata['close_code'] = $closeDiagnostics['code'] ?? null;
        $metadata['close_stage'] = $closeDiagnostics['stage'] ?? null;
        $metadata['close_diagnostics'] = $closeDiagnostics;
        $metadata['transcript_bytes'] = strlen($transcript);

        $session->status->set($reason === 'agent_exit' ? 'COMPLETED' : 'CLOSED');
        $session->closed_at->set($this->now());
        $session->transcript->set($transcript);
        $session->metadata_json->set($metadata);

        $this->writeAudit(
            (int)$session->gateway_id->value(),
            (int)$session->department_id->value(),
            'GATEWAY_SHELL_SESSION_CLOSED',
            $session->actor_user_id->value() === null ? null : (int)$session->actor_user_id->value(),
            ['shell_session_id' => (int)$session->id, 'reason' => $reason]
        );

        edge_gateway_view_cache::syncGateway($this->getGateway((int)$session->gateway_id->value()));

        return $session->asArray();
    }

    private function normalizeShellCloseDiagnostics(?string $reason, array $details = []): array
    {
        $nestedDetails = isset($details['details']) && is_array($details['details']) ? (array)$details['details'] : [];
        $message = self::trimInstallSessionText(
            $details['message'] ?? $nestedDetails['message'] ?? null,
            self::INSTALL_SESSION_OUTPUT_LIMIT
        );
        $stage = self::trimInstallSessionText(
            $details['stage'] ?? $details['failure_stage'] ?? $nestedDetails['stage'] ?? null,
            128
        );
        $codeValue = $details['code'] ?? $details['close_code'] ?? $nestedDetails['code'] ?? null;
        $code = is_numeric($codeValue) ? (int)$codeValue : null;
        $diagnostics = [
            'reason' => self::trimInstallSessionText($reason, 128),
            'message' => $message,
            'code' => $code,
            'stage' => $stage,
            'was_clean' => array_key_exists('was_clean', $details) ? (bool)$details['was_clean'] : null,
            'connection_id' => self::trimInstallSessionText(
                $details['connection_id'] ?? $details['broker_connection_id'] ?? $nestedDetails['connection_id'] ?? null,
                128
            ),
            'broker_url' => self::trimInstallSessionText($details['broker_url'] ?? $nestedDetails['broker_url'] ?? null, 512),
            'ws_url' => self::trimInstallSessionText(
                $this->redactShellDiagnosticUrl($details['ws_url'] ?? $nestedDetails['ws_url'] ?? null),
                512
            ),
            'closed_at' => $this->now(),
        ];

        return array_filter($diagnostics, static fn(mixed $value): bool => $value !== null && $value !== '');
    }

    private function redactShellDiagnosticUrl(mixed $value): ?string
    {
        if ($value === null) {
            return null;
        }

        $url = trim((string)$value);
        if ($url === '') {
            return null;
        }

        return (string)preg_replace('/([?&](?:token|agentToken|agent_token)=)[^&]*/i', '$1***', $url);
    }

    /**
     * @throws Exception
     */
    public function appendGatewayLogEntry(
        int $gatewayId,
        string $message,
        string $level = 'INFO',
        string $stream = 'agent',
        string $source = 'BROKER',
        array $context = []
    ): array {
        $gateway = $this->requireGateway($gatewayId);
        $logEntryId = (new edge_gateway_log_entries_o())->add_object([
            'gateway_id' => $gatewayId,
            'department_id' => (int)$gateway->department_id->value(),
            'level' => strtoupper(trim($level)) ?: 'INFO',
            'stream' => trim($stream) !== '' ? trim($stream) : 'agent',
            'source' => trim($source) !== '' ? trim($source) : 'BROKER',
            'message' => $message,
            'context_json' => $context,
        ]);

        edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId));

        return (new edge_gateway_log_entries_o())->select($logEntryId)->asArray();
    }

    /**
     * @throws Exception
     */
    public function appendRelayTransportLog(
        int $departmentId,
        string $endpoint,
        array $payload,
        array|object|null $response,
        string $handler = 'cloud',
        ?string $errorMessage = null,
        array $actionContext = []
    ): ?array {
        try {
            $gateway = $this->getPrimaryGatewayForDepartment($departmentId, false);
        } catch (Exception) {
            return null;
        }

        $relayId = $this->inferRelayIdFromTransportPayload($payload);
        $success = $errorMessage === null || trim($errorMessage) === '';
        $targetOn = array_key_exists('on', $payload) ? (bool)$payload['on'] : null;
        $handler = strtolower(trim($handler)) === 'local' ? 'local' : 'cloud';

        return $this->appendRelayDispatchLog(
            [
                'id' => null,
                'gateway_id' => (int)$gateway->id,
                'department_id' => $departmentId,
                'relay_id' => $relayId,
                'device_id' => $payload['deviceId'] ?? $payload['device_id'] ?? null,
                'channel' => $payload['channel'] ?? null,
            ],
            [
                'execution_path' => $handler,
                'delivery_channel' => $handler === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : self::DELIVERY_CHANNEL_API,
                'reason' => 'direct_transport',
            ],
            $success,
            [
                'action' => $this->inferRelayActionFromEndpoint($endpoint),
                'handler' => $handler,
                'relay_id' => $relayId,
                'target_on' => $targetOn,
                'toggle_after_seconds' => $this->normalizeRelayToggleAfter(
                    isset($payload['toggle_after']) || isset($payload['toggleAfter']) || isset($payload['timer'])
                        ? (int)($payload['toggle_after'] ?? $payload['toggleAfter'] ?? $payload['timer'])
                        : null
                ),
                'signal' => [
                    'endpoint' => $endpoint,
                    'request' => $payload,
                ],
                'action_context' => $this->normalizeRelayActionContext($actionContext),
            ],
            $this->normalizeRelayTransportResponse($response),
            $success ? null : new Exception($errorMessage ?? 'Relay transport request failed')
        );
    }

    /**
     * @throws Exception
     */
    public function recordTelemetryFromBroker(int $gatewayId, array $payload): array
    {
        $gateway = $this->requireGateway($gatewayId);
        $now = $this->now();
        $metadata = array_merge(
            (array)($gateway->metadata_json->value() ?? []),
            isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
        );
        $existingPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence'])
            ? (array)$metadata['broker_presence']
            : [];
        $presenceMetadata = isset($existingPresence['metadata']) && is_array($existingPresence['metadata'])
            ? (array)$existingPresence['metadata']
            : [];
        $payloadMetadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [];
        $connectionId = trim((string)($payload['broker_connection_id'] ?? $existingPresence['connection_id'] ?? ''));
        $presence = [
            'gateway_id' => $gatewayId,
            'connected' => true,
            'connection_id' => $connectionId !== '' ? $connectionId : null,
            'last_seen_at' => $now,
            'disconnect_reason' => null,
            'last_error' => null,
            'metadata' => array_merge($presenceMetadata, array_filter([
                'agent_instance_id' => $payload['broker_agent_instance_id']
                    ?? $payloadMetadata['agent_instance_id']
                    ?? null,
            ], static fn(mixed $value): bool => $value !== null && $value !== '')),
        ];
        $metadata['broker_presence'] = $presence;
        $metadata['broker_connected'] = true;
        $metadata['broker_connected_at'] = $now;
        $metadata['broker_last_error'] = null;
        $this->writeBrokerPresence($gatewayId, $presence);

        $gateway->status->set((string)($payload['status'] ?? self::STATUS_ONLINE));
        $gateway->hostname->set($payload['hostname'] ?? $gateway->hostname->value());
        $gateway->installed_version->set($payload['installed_version'] ?? $gateway->installed_version->value());
        $gateway->target_version->set($payload['target_version'] ?? $gateway->target_version->value());
        $gateway->last_heartbeat_at->set($now);
        $gateway->metadata_json->set($metadata);

        if (isset($payload['inventory']) && is_array($payload['inventory'])) {
            $this->syncDeviceInventory($gatewayId, (array)$payload['inventory']);
        }

        $gatewayPayload = $this->getGateway($gatewayId);
        edge_gateway_view_cache::syncGateway($gatewayPayload);
        return $gatewayPayload;
    }

    private function buildUpdateCommandPayload(string $targetVersion, string $releaseChannel): array
    {
        return [
            'targetVersion' => $targetVersion,
            'releaseChannel' => $releaseChannel,
            'artifactUrl' => $this->buildAgentArtifactUrl('agent.php'),
            'artifactSha256' => $this->buildAgentArtifactSha256('agent.php'),
            'serviceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AGENT_SERVICE_NAME),
            'serviceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AGENT_SERVICE_NAME),
            'serviceName' => self::DEFAULT_AGENT_SERVICE_NAME,
            'runtimeMode' => 'compose',
            'installDir' => self::DEFAULT_INSTALL_DIR,
            'runtimeDir' => self::DEFAULT_RUNTIME_DIR,
            'stackServiceName' => self::DEFAULT_STACK_SERVICE_NAME,
            'stackServiceUnitUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_STACK_SERVICE_NAME),
            'stackServiceUnitSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_STACK_SERVICE_NAME),
            'composeFileName' => self::DEFAULT_COMPOSE_STACK_FILE,
            'composeFileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_COMPOSE_STACK_FILE),
            'composeFileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_COMPOSE_STACK_FILE),
            'composeProjectName' => self::DEFAULT_COMPOSE_PROJECT_NAME,
            'launcherScriptName' => self::DEFAULT_LAUNCHER_SCRIPT_NAME,
            'launcherScriptUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAUNCHER_SCRIPT_NAME),
            'launcherScriptSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAUNCHER_SCRIPT_NAME),
            'lanWorkerArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_ARTIFACT),
            'lanWorkerArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_ARTIFACT),
            'autoUpdaterArtifactUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_ARTIFACT),
            'autoUpdaterArtifactSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AUTO_UPDATER_ARTIFACT),
            'edgeAgentDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_EDGE_AGENT_DOCKERFILE),
            'edgeAgentDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_EDGE_AGENT_DOCKERFILE),
            'lanWorkerDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_LAN_WORKER_DOCKERFILE),
            'lanWorkerDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_LAN_WORKER_DOCKERFILE),
            'autoUpdaterDockerfileUrl' => $this->buildAgentArtifactUrl(self::DEFAULT_AUTO_UPDATER_DOCKERFILE),
            'autoUpdaterDockerfileSha256' => $this->buildAgentArtifactSha256(self::DEFAULT_AUTO_UPDATER_DOCKERFILE),
            'stateDatabasePath' => self::DEFAULT_STATE_DATABASE_PATH,
            'workerBaseUrl' => self::DEFAULT_WORKER_BASE_URL,
            'updateWindow' => self::configuredDefaultUpdateWindow(),
            'edgeAgentBaseImage' => self::DEFAULT_EDGE_AGENT_BASE_IMAGE,
            'lanWorkerBaseImage' => self::DEFAULT_LAN_WORKER_BASE_IMAGE,
            'autoUpdaterBaseImage' => self::DEFAULT_AUTO_UPDATER_BASE_IMAGE,
            'redisBaseImage' => self::DEFAULT_REDIS_BASE_IMAGE,
            'mariadbBaseImage' => self::DEFAULT_MARIADB_BASE_IMAGE,
            'minioBaseImage' => self::DEFAULT_MINIO_BASE_IMAGE,
        ];
    }

    private function buildAgentArtifactUrl(string $fileName): string
    {
        return rtrim($this->getApiBaseUrl(), '/') . '/edge-agent/artifacts/' . $fileName;
    }

    private function buildAgentArtifactPath(string $fileName): string
    {
        return edge_gateway_agent_artifact_locator::resolve($fileName);
    }

    /**
     * @throws Exception
     */
    private function buildAgentArtifactSha256(string $fileName): string
    {
        $artifactPath = $this->buildAgentArtifactPath($fileName);
        if (!is_file($artifactPath)) {
            throw new Exception('Missing edge agent artifact: ' . $fileName);
        }

        $sha256 = hash_file('sha256', $artifactPath);
        if ($sha256 === false) {
            throw new Exception('Unable to checksum edge agent artifact: ' . $fileName);
        }

        return $sha256;
    }

    /**
     * @throws Exception
     */
    public function verifyInstallToken(string $plainToken): array
    {
        $claimToken = $this->requireClaimToken($plainToken);
        if ($claimToken->used_at->value() !== null) {
            throw new Exception('Install token has already been used');
        }

        return [
            'valid' => true,
            'claim_token_id' => (int)$claimToken->id,
            'department_id' => (int)$claimToken->department_id->value(),
            'label' => $claimToken->label->value(),
            'expires_at' => (string)$claimToken->expires_at->value(),
        ];
    }

    /**
     * @throws Exception
     */
    public function getInstallTokenStatus(int $claimTokenId): array
    {
        return $this->buildInstallTokenStatusPayload($this->requireClaimTokenById($claimTokenId));
    }

    /**
     * @throws Exception
     */
    public function reportInstallTokenStatus(string $plainToken, array $payload): array
    {
        $claimToken = $this->requireClaimToken($plainToken);
        $status = strtoupper(trim((string)($payload['status'] ?? self::INSTALL_SESSION_STATUS_RUNNING)));
        $step = trim((string)($payload['step'] ?? ($status === self::INSTALL_SESSION_STATUS_FAILED ? 'FAILED' : $status)));
        $message = trim((string)($payload['message'] ?? ''));
        $update = [
            'status' => $status,
            'step' => $step !== '' ? $step : ($status === self::INSTALL_SESSION_STATUS_FAILED ? 'FAILED' : null),
            'message' => $message !== '' ? $message : null,
            'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
        ];

        if (array_key_exists('gateway_id', $payload)) {
            $update['gateway_id'] = (int)$payload['gateway_id'];
        }
        if (array_key_exists('last_error', $payload)) {
            $update['last_error'] = $payload['last_error'];
        } elseif ($status === self::INSTALL_SESSION_STATUS_FAILED) {
            $update['last_error'] = $message !== '' ? $message : 'Installer failed.';
        } elseif ($status === self::INSTALL_SESSION_STATUS_CLAIMED) {
            $update['last_error'] = null;
        }

        return $this->persistInstallSession($claimToken, $update);
    }

    public static function installSessionStatusIsTerminal(string $status): bool
    {
        return in_array(
            strtoupper(trim($status)),
            [
                self::INSTALL_SESSION_STATUS_CLAIMED,
                self::INSTALL_SESSION_STATUS_FAILED,
                self::INSTALL_SESSION_STATUS_EXPIRED,
            ],
            true
        );
    }

    /**
     * @param array<string,mixed> $session
     * @param array<string,mixed> $update
     * @return array<string,mixed>
     */
    public static function mergeInstallSessionUpdate(array $session, array $update, ?int $now = null): array
    {
        $timestamp = date('Y-m-d H:i:s', $now ?? time());
        $status = strtoupper(trim((string)($update['status'] ?? $session['status'] ?? self::INSTALL_SESSION_STATUS_PENDING)));
        $step = trim((string)($update['step'] ?? $session['step'] ?? ''));
        $message = self::trimInstallSessionText($update['message'] ?? ($session['message'] ?? null), self::INSTALL_SESSION_OUTPUT_LIMIT);
        $startedAt = isset($session['started_at']) ? self::trimInstallSessionText($session['started_at'], 64) : null;
        if ($startedAt === null && $status !== self::INSTALL_SESSION_STATUS_PENDING) {
            $startedAt = $timestamp;
        }

        $gatewayId = array_key_exists('gateway_id', $update) ? (int)$update['gateway_id'] : (int)($session['gateway_id'] ?? 0);
        $lastError = array_key_exists('last_error', $update)
            ? self::trimInstallSessionText($update['last_error'], self::INSTALL_SESSION_OUTPUT_LIMIT)
            : self::trimInstallSessionText($session['last_error'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT);
        if ($status === self::INSTALL_SESSION_STATUS_CLAIMED) {
            $lastError = null;
        } elseif ($status === self::INSTALL_SESSION_STATUS_FAILED && $lastError === null) {
            $lastError = $message ?? 'Installer failed.';
        }

        $diagnostics = array_key_exists('diagnostics', $update)
            ? self::sanitizeInstallSessionDiagnostics($update['diagnostics'])
            : self::sanitizeInstallSessionDiagnostics($session['diagnostics'] ?? []);
        if ($status === self::INSTALL_SESSION_STATUS_CLAIMED) {
            $diagnostics = [];
        }

        $events = self::sanitizeInstallSessionEvents($session['events'] ?? []);
        $shouldRecordEvent = !array_key_exists('record_event', $update) || $update['record_event'] !== false;
        if ($shouldRecordEvent) {
            $events[] = array_filter([
                'status' => $status,
                'step' => $step !== '' ? $step : null,
                'message' => $message,
                'at' => $timestamp,
            ], static fn(mixed $value): bool => $value !== null && $value !== '');
        }
        $events = self::sanitizeInstallSessionEvents($events);

        return [
            'status' => $status,
            'step' => $step !== '' ? $step : null,
            'message' => $message,
            'started_at' => $startedAt,
            'updated_at' => $timestamp,
            'gateway_id' => $gatewayId > 0 ? $gatewayId : null,
            'last_error' => $lastError,
            'diagnostics' => $diagnostics,
            'events' => $events,
        ];
    }

    /**
     * @param array<string,mixed> $session
     * @return array<string,mixed>
     */
    public static function normalizeInstallSessionRecord(array $session, ?string $expiresAt, ?int $now = null): array
    {
        $normalized = [
            'status' => strtoupper(trim((string)($session['status'] ?? self::INSTALL_SESSION_STATUS_PENDING))),
            'step' => self::trimInstallSessionText($session['step'] ?? null, 64),
            'message' => self::trimInstallSessionText($session['message'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT),
            'started_at' => self::trimInstallSessionText($session['started_at'] ?? null, 64),
            'updated_at' => self::trimInstallSessionText($session['updated_at'] ?? null, 64),
            'gateway_id' => (($session['gateway_id'] ?? null) !== null && (int)$session['gateway_id'] > 0) ? (int)$session['gateway_id'] : null,
            'last_error' => self::trimInstallSessionText($session['last_error'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT),
            'diagnostics' => self::sanitizeInstallSessionDiagnostics($session['diagnostics'] ?? []),
            'events' => self::sanitizeInstallSessionEvents($session['events'] ?? []),
        ];
        $status = (string)$normalized['status'];
        if (
            !self::installSessionStatusIsTerminal($status)
            && $expiresAt !== null
            && (self::parseApplicationDateTime($expiresAt) ?? PHP_INT_MAX) < ($now ?? time())
        ) {
            $status = self::INSTALL_SESSION_STATUS_EXPIRED;
            $normalized['status'] = $status;
            $normalized['message'] = $normalized['message'] ?: 'Installer token expired before the gateway claimed successfully.';
            $normalized['last_error'] = $normalized['last_error'] ?: 'Install token expired.';
        }

        $normalized['terminal'] = self::installSessionStatusIsTerminal((string)$status);
        return $normalized;
    }

    /**
     * @param mixed $diagnostics
     * @return array<int,array<string,string>>
     */
    private static function sanitizeInstallSessionDiagnostics(mixed $diagnostics): array
    {
        if (!is_array($diagnostics)) {
            return [];
        }

        $normalized = [];
        foreach ($diagnostics as $diagnostic) {
            if (is_string($diagnostic)) {
                $output = self::trimInstallSessionText($diagnostic, self::INSTALL_SESSION_OUTPUT_LIMIT);
                if ($output === null) {
                    continue;
                }

                $normalized[] = [
                    'name' => 'Diagnostic',
                    'output' => $output,
                ];
                continue;
            }

            if (!is_array($diagnostic)) {
                continue;
            }

            $name = self::trimInstallSessionText($diagnostic['name'] ?? $diagnostic['title'] ?? null, 120);
            $output = self::trimInstallSessionText($diagnostic['output'] ?? $diagnostic['body'] ?? null, self::INSTALL_SESSION_OUTPUT_LIMIT);
            if ($name === null || $output === null) {
                continue;
            }

            $normalized[] = [
                'name' => $name,
                'output' => $output,
            ];
        }

        return array_slice($normalized, -self::INSTALL_SESSION_DIAGNOSTIC_LIMIT);
    }

    /**
     * @param mixed $events
     * @return array<int,array<string,string>>
     */
    private static function sanitizeInstallSessionEvents(mixed $events): array
    {
        if (!is_array($events)) {
            return [];
        }

        $normalized = [];
        foreach ($events as $event) {
            if (!is_array($event)) {
                continue;
            }

            $status = self::trimInstallSessionText($event['status'] ?? null, 32);
            $step = self::trimInstallSessionText($event['step'] ?? null, 64);
            $message = self::trimInstallSessionText($event['message'] ?? null, 512);
            $at = self::trimInstallSessionText($event['at'] ?? null, 64);

            $normalized[] = array_filter([
                'status' => $status,
                'step' => $step,
                'message' => $message,
                'at' => $at,
            ], static fn(mixed $value): bool => $value !== null && $value !== '');
        }

        return array_slice($normalized, -self::INSTALL_SESSION_EVENT_LIMIT);
    }

    private static function trimInstallSessionText(mixed $value, int $limit): ?string
    {
        if ($value === null) {
            return null;
        }

        $text = trim((string)$value);
        if ($text === '') {
            return null;
        }

        if (strlen($text) <= $limit) {
            return $text;
        }

        return substr($text, 0, max(0, $limit - 3)) . '...';
    }

    /**
     * @throws Exception
     */
    private function buildInstallTokenStatusPayload(edge_gateway_claim_tokens_o $claimToken): array
    {
        $metadata = (array)($claimToken->metadata_json->value() ?? []);
        $session = self::normalizeInstallSessionRecord(
            isset($metadata['install_session']) && is_array($metadata['install_session'])
                ? (array)$metadata['install_session']
                : [],
            (string)$claimToken->expires_at->value()
        );

        return [
            'claim_token_id' => (int)$claimToken->id,
            'department_id' => (int)$claimToken->department_id->value(),
            'label' => $claimToken->label->value() === null ? null : (string)$claimToken->label->value(),
            'expires_at' => (string)$claimToken->expires_at->value(),
            'status' => (string)$session['status'],
            'step' => $session['step'] ?? null,
            'message' => $session['message'] ?? null,
            'started_at' => $session['started_at'] ?? null,
            'updated_at' => $session['updated_at'] ?? null,
            'terminal' => (bool)($session['terminal'] ?? false),
            'gateway_id' => $session['gateway_id'] ?? null,
            'last_error' => $session['last_error'] ?? null,
            'diagnostics' => $session['diagnostics'] ?? [],
            'events' => $session['events'] ?? [],
        ];
    }

    /**
     * @throws Exception
     */
    private function persistInstallSession(edge_gateway_claim_tokens_o $claimToken, array $update): array
    {
        $metadata = (array)($claimToken->metadata_json->value() ?? []);
        $metadata['install_session'] = self::mergeInstallSessionUpdate(
            isset($metadata['install_session']) && is_array($metadata['install_session'])
                ? (array)$metadata['install_session']
                : [],
            $update
        );
        $claimToken->metadata_json->set($metadata);

        return $this->buildInstallTokenStatusPayload($claimToken);
    }

    public function getApiBaseUrl(): string
    {
        $configured = trim((string)(getenv('EDGE_PUBLIC_API_URL') ?: ''));
        if ($configured !== '') {
            return $configured;
        }

        $forwardedScheme = $this->detectForwardedScheme();
        if ($forwardedScheme !== null) {
            $scheme = strtolower($forwardedScheme) === 'https' ? 'https' : 'http';
        } else {
            $requestScheme = strtolower(trim((string)($_SERVER['REQUEST_SCHEME'] ?? '')));
            $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || $requestScheme === 'https' ? 'https' : 'http';
        }

        $host = trim((string)($this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_HOST'] ?? null) ?? ($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? 'localhost')));
        if ($host === '') {
            $host = 'localhost';
        }

        $forwardedPort = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PORT'] ?? null);
        $port = $forwardedPort !== null ? (int)$forwardedPort : 0;
        if ($port <= 0) {
            $hostPort = parse_url($scheme . '://' . $host, PHP_URL_PORT);
            $port = is_int($hostPort) ? $hostPort : (int)($_SERVER['SERVER_PORT'] ?? 0);
        }
        if ($scheme === 'http' && in_array($port, [443, 4433], true)) {
            $scheme = 'https';
        }
        if ($port > 0 && !str_contains($host, ':') && !(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) {
            $host .= ':' . $port;
        }

        $forwardedPrefix = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PREFIX'] ?? null);
        if ($forwardedPrefix !== null) {
            $normalizedForwardedPrefix = '/' . trim($forwardedPrefix, '/');
            $basePath = $normalizedForwardedPrefix === '/' ? '' : $normalizedForwardedPrefix;
        } else {
            $requestPath = parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
            $basePath = (is_string($requestPath) && preg_match('#^/api(?:/|$)#', $requestPath) === 1) ? '/api' : '';
        }

        return $scheme . '://' . $host . $basePath;
    }

    private function detectForwardedScheme(): ?string
    {
        $forwardedScheme = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? null);
        if ($forwardedScheme !== null) {
            return $forwardedScheme;
        }

        $forwardedScheme = $this->firstForwardedHeaderValue($_SERVER['HTTP_X_FORWARDED_PROTOCOL'] ?? null);
        if ($forwardedScheme !== null) {
            return $forwardedScheme;
        }

        $forwardedHeader = trim((string)($_SERVER['HTTP_FORWARDED'] ?? ''));
        if ($forwardedHeader !== '' && preg_match('/proto=([^;,\s]+)/i', $forwardedHeader, $matches) === 1) {
            return trim($matches[1], "\"'");
        }

        return null;
    }

    private function firstForwardedHeaderValue(mixed $value): ?string
    {
        if (!is_string($value)) {
            return null;
        }

        foreach (explode(',', $value) as $segment) {
            $normalized = trim($segment);
            if ($normalized !== '') {
                return $normalized;
            }
        }

        return null;
    }

    /**
     * @throws Exception
     */
    private function requireDispatchableGateway(int $gatewayId): edge_gateways_o
    {
        $gateway = $this->requireGateway($gatewayId);
        $effectiveStatus = self::resolveGatewayStatus(
            $gateway->status->value() === null ? null : (string)$gateway->status->value(),
            $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
        );

        if (!in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) {
            throw new Exception('Gateway agent is offline');
        }

        return $gateway;
    }

    /**
     * @throws Exception
     */
    private function requireGateway(int $gatewayId): edge_gateways_o
    {
        $gateway = (new edge_gateways_o())->select($gatewayId);
        if (!$gateway->exists() || $gateway->deleted_at->value() !== null) {
            throw new Exception('Edge gateway not found');
        }

        if (!$this->departmentExists((int)$gateway->department_id->value())) {
            $this->softDeleteOrphanedGateway($gateway);
            throw new Exception('Edge gateway not found');
        }

        return $gateway;
    }

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

        try {
            return (new departments_o())->select($departmentId)->exists();
        } catch (\Throwable) {
            return true;
        }
    }

    private function softDeleteOrphanedGateway(edge_gateways_o $gateway): void
    {
        $gatewayId = (int)$gateway->id;
        $departmentId = (int)$gateway->department_id->value();

        try {
            $this->softDeleteGatewayRelations($gatewayId);
        } catch (\Throwable) {
        }

        try {
            if ($gateway->deleted_at->value() === null) {
                $gateway->deleted_at->set($this->now());
            }
        } catch (\Throwable) {
        }

        edge_gateway_view_cache::removeGateway($gatewayId, $departmentId > 0 ? $departmentId : null);
    }

    private function softDeleteGatewayRelations(int $gatewayId): void
    {
        $tables = [
            'edge_gateway_device_inventory',
            'edge_gateway_relay_bindings',
            'edge_gateway_command_jobs',
            'edge_gateway_operations',
        ];
        $pdo = db::getPDO();
        $deletedAt = $this->now();

        foreach ($tables as $table) {
            $statement = $pdo->prepare(
                "UPDATE {$table}
                SET deleted_at = :deleted_at
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL"
            );
            $statement->execute([
                ':deleted_at' => $deletedAt,
                ':gateway_id' => $gatewayId,
            ]);
        }
    }

    /**
     * @throws Exception
     */
    private function requireDepartment(int $departmentId): departments_o
    {
        $department = (new departments_o())->select($departmentId);
        if (!$department->exists()) {
            throw new Exception('Department not found');
        }

        return $department;
    }

    /**
     * @throws Exception
     */
    private function requireClaimToken(string $plainToken): edge_gateway_claim_tokens_o
    {
        $rows = (new edge_gateway_claim_tokens_o())->getFieldsWhere([
            'token_hash' => $this->hashToken($plainToken),
            'deleted_at' => null,
        ], ['id']);

        if ($rows === []) {
            throw new Exception('Invalid install token');
        }

        $claimToken = (new edge_gateway_claim_tokens_o())->select((int)$rows[0]['id']);
        if (!$claimToken->exists()) {
            throw new Exception('Invalid install token');
        }
        if ((self::parseApplicationDateTime((string)$claimToken->expires_at->value()) ?? 0) < time()) {
            throw new Exception('Install token has expired');
        }

        return $claimToken;
    }

    /**
     * @throws Exception
     */
    private function requireClaimTokenById(int $claimTokenId): edge_gateway_claim_tokens_o
    {
        if ($claimTokenId <= 0) {
            throw new Exception('Invalid install token');
        }

        $claimToken = (new edge_gateway_claim_tokens_o())->select($claimTokenId);
        if (!$claimToken->exists() || $claimToken->deleted_at->value() !== null) {
            throw new Exception('Invalid install token');
        }

        return $claimToken;
    }

    /**
     * @throws Exception
     */
    private function getPrimaryGatewayForDepartment(int $departmentId, bool $requireDispatchable = true): edge_gateways_o
    {
        $rows = (new edge_gateways_o())->getFieldsWhere([
            'department_id' => $departmentId,
            'deleted_at' => null,
        ], ['id']);

        if ($rows === []) {
            throw new Exception('No edge gateway found for department');
        }

        $gatewayIds = array_map(static fn(array $row): int => (int)$row['id'], $rows);
        $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds);
        usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int {
            $aStatus = self::resolveGatewayStatus(
                $a->status->value() === null ? null : (string)$a->status->value(),
                $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value()
            );
            $bStatus = self::resolveGatewayStatus(
                $b->status->value() === null ? null : (string)$b->status->value(),
                $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()
            );

            return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value())
                ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus))
                ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value())
                    <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value()));
        });

        $gateway = $gateways[0];
        $effectiveStatus = self::resolveGatewayStatus(
            $gateway->status->value() === null ? null : (string)$gateway->status->value(),
            $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
        );

        if ($requireDispatchable && !in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) {
            throw new Exception('Department edge gateway is offline');
        }

        return $gateway;
    }

    /**
     * @throws Exception
     */
    private function setGatewayPrimaryState(edge_gateways_o $gateway, bool $isPrimary): void
    {
        if ($isPrimary) {
            $statement = db::getPDO()->prepare(
                "UPDATE edge_gateways
                 SET is_primary = CASE WHEN id = :gateway_id THEN 1 ELSE 0 END
                 WHERE department_id = :department_id
                   AND deleted_at IS NULL"
            );
            $statement->execute([
                ':gateway_id' => (int)$gateway->id,
                ':department_id' => (int)$gateway->department_id->value(),
            ]);
            $gateway->is_primary->set(true);
            return;
        }

        $replacement = $this->findAlternateGatewayForDepartment(
            (int)$gateway->department_id->value(),
            (int)$gateway->id
        );
        if ($replacement === null) {
            throw new Exception('Department must retain a primary gateway');
        }

        $replacement->is_primary->set(true);
        $gateway->is_primary->set(false);
    }

    private function findAlternateGatewayForDepartment(int $departmentId, int $excludedGatewayId): ?edge_gateways_o
    {
        $rows = (new edge_gateways_o())->getFieldsWhere([
            'department_id' => $departmentId,
            'deleted_at' => null,
        ], ['id']);

        $gatewayIds = array_values(array_filter(
            array_map(static fn(array $row): int => (int)$row['id'], $rows),
            static fn(int $gatewayId): bool => $gatewayId !== $excludedGatewayId
        ));

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

        $gateways = array_map(static fn(int $id): edge_gateways_o => (new edge_gateways_o())->select($id), $gatewayIds);
        usort($gateways, static function (edge_gateways_o $a, edge_gateways_o $b): int {
            $aStatus = self::resolveGatewayStatus(
                $a->status->value() === null ? null : (string)$a->status->value(),
                $a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value()
            );
            $bStatus = self::resolveGatewayStatus(
                $b->status->value() === null ? null : (string)$b->status->value(),
                $b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value()
            );

            return ((int)$b->is_primary->value() <=> (int)$a->is_primary->value())
                ?: (self::statusPriority($bStatus) <=> self::statusPriority($aStatus))
                ?: (self::heartbeatTimestamp($b->last_heartbeat_at->value() === null ? null : (string)$b->last_heartbeat_at->value())
                    <=> self::heartbeatTimestamp($a->last_heartbeat_at->value() === null ? null : (string)$a->last_heartbeat_at->value()));
        });

        return $gateways[0] ?? null;
    }

    private function createCommandJob(
        int $gatewayId,
        string $commandType,
        array $request,
        ?int $userId,
        array $delivery = []
    ): edge_gateway_command_jobs_o
    {
        $deliveryMetadata = $this->buildDeliveryMetadata($delivery, self::COMMAND_EXPIRES_AFTER_SECONDS);
        $jobObject = new edge_gateway_command_jobs_o();
        $jobId = $jobObject->add_object([
            'gateway_id' => $gatewayId,
            'command_type' => $commandType,
            'status' => 'PENDING',
            'request_json' => $request,
            'response_json' => [],
            'delivery_json' => $deliveryMetadata,
            'correlation_id' => bin2hex(random_bytes(16)),
            'requested_by' => $userId,
            'requested_at' => $this->now(),
        ]);

        return $jobObject->select($jobId);
    }

    private function expireTimedOutRelayStatusCommandJobs(int $gatewayId): void
    {
        if ($gatewayId <= 0) {
            return;
        }

        $statement = db::getPDO()->prepare(
            "SELECT id
             FROM edge_gateway_command_jobs
             WHERE gateway_id = :gateway_id
               AND deleted_at IS NULL
               AND command_type = 'GET_RELAY_STATUS'
               AND status IN ('PENDING', 'DISPATCHING')
               AND requested_at <= :cutoff
             ORDER BY requested_at ASC, id ASC
             LIMIT 200"
        );
        $statement->execute([
            ':gateway_id' => $gatewayId,
            ':cutoff' => $this->formatDateTime(time() - self::COMMAND_WAIT_TIMEOUT_SECONDS),
        ]);

        foreach ($statement->fetchAll() ?: [] as $row) {
            $jobId = (int)($row['id'] ?? 0);
            if ($jobId <= 0) {
                continue;
            }

            $job = (new edge_gateway_command_jobs_o())->select($jobId);
            if ($job->exists()) {
                $this->finalizeCommandJob($job, false, [], 'Edge gateway command timed out', null, 'TIMED_OUT');
            }
        }
    }

    /**
     * @throws Exception
     */
    private function waitForCommandResult(int $jobId, int $timeoutSeconds = self::COMMAND_WAIT_TIMEOUT_SECONDS): array
    {
        $deadline = microtime(true) + max(0, $timeoutSeconds);

        do {
            $job = (new edge_gateway_command_jobs_o())->select($jobId);
            if (!$job->exists()) {
                throw new Exception('Edge gateway command job not found');
            }

            $status = (string)$job->status->value();
            if ($status === 'COMPLETED') {
                $response = (array)($job->response_json->value() ?? []);
                return (array)($response['payload'] ?? []);
            }

            if ($status === 'FAILED') {
                $errorMessage = trim((string)($job->error_message->value() ?? ''));
                throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed');
            }
            if ($status === 'TIMED_OUT') {
                $errorMessage = trim((string)($job->error_message->value() ?? ''));
                throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command timed out');
            }

            if (microtime(true) >= $deadline) {
                break;
            }

            usleep(self::COMMAND_POLL_INTERVAL_MICROSECONDS);
        } while (true);

        $job = (new edge_gateway_command_jobs_o())->select($jobId);
        if ($job->exists()) {
            $status = (string)$job->status->value();
            if ($status === 'COMPLETED') {
                $response = (array)($job->response_json->value() ?? []);
                return (array)($response['payload'] ?? []);
            }
            if ($status === 'FAILED') {
                $errorMessage = trim((string)($job->error_message->value() ?? ''));
                throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command failed');
            }
            if ($status === 'TIMED_OUT') {
                $errorMessage = trim((string)($job->error_message->value() ?? ''));
                throw new Exception($errorMessage !== '' ? $errorMessage : 'Edge gateway command timed out');
            }

            $this->finalizeCommandJob($job, false, [], 'Edge gateway command timed out', null, 'TIMED_OUT');
        }

        throw new Exception('Edge gateway command timed out');
    }

    private function claimNextCommandJob(edge_gateways_o $gateway): ?edge_gateway_command_jobs_o
    {
        $pdo = db::getPDO();
        $pdo->beginTransaction();

        try {
            $statement = $pdo->prepare(
                'SELECT id, delivery_json
                 FROM edge_gateway_command_jobs
                 WHERE gateway_id = :gateway_id
                   AND deleted_at IS NULL
                   AND (
                     status = :pending_status_match
                     OR (
                       status = :dispatching_status_match
                       AND COALESCE(updated_at, created_at, requested_at) <= :stale_before
                       AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(delivery_json, \'$.no_auto_retry\')), \'false\') <> \'true\'
                     )
                   )
                 ORDER BY CASE WHEN status = :pending_status_order THEN 0 ELSE 1 END, requested_at ASC, id ASC
                 LIMIT 1
                 FOR UPDATE'
            );
            $statement->execute([
                ':gateway_id' => (int)$gateway->id,
                ':pending_status_match' => 'PENDING',
                ':dispatching_status_match' => 'DISPATCHING',
                ':pending_status_order' => 'PENDING',
                ':stale_before' => $this->formatDateTime(time() - self::COMMAND_DISPATCH_STALE_AFTER_SECONDS),
            ]);

            $row = $statement->fetch();
            if (!is_array($row) || !isset($row['id'])) {
                $pdo->commit();
                return null;
            }

            $delivery = isset($row['delivery_json']) && is_string($row['delivery_json'])
                ? json_decode($row['delivery_json'], true)
                : [];
            if (!is_array($delivery)) {
                $delivery = [];
            }
            $delivery['delivery_channel'] = self::DELIVERY_CHANNEL_API;
            $delivery['attempt_count'] = ((int)($delivery['attempt_count'] ?? 0)) + 1;
            $delivery['last_dispatch_error'] = null;
            $encodedDelivery = json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
            if (!is_string($encodedDelivery)) {
                $encodedDelivery = '{}';
            }

            $update = $pdo->prepare(
                'UPDATE edge_gateway_command_jobs
                 SET status = :status,
                     response_json = :response_json,
                     delivery_json = :delivery_json,
                     error_message = NULL,
                     completed_at = NULL
                 WHERE id = :id'
            );
            $update->execute([
                ':status' => 'DISPATCHING',
                ':response_json' => json_encode([], JSON_UNESCAPED_UNICODE),
                ':delivery_json' => $encodedDelivery,
                ':id' => (int)$row['id'],
            ]);

            $pdo->commit();
        } catch (\Throwable $throwable) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }
            throw $throwable;
        }

        $this->clearObjectPropertyCache('edge_gateway_command_jobs', (int)$row['id']);
        return (new edge_gateway_command_jobs_o())->select((int)$row['id']);
    }

    private function formatAgentCommandJob(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array
    {
        return [
            'id' => (int)$job->id,
            'gateway_id' => (int)$gateway->id,
            'department_id' => (int)$gateway->department_id->value(),
            'command_type' => (string)$job->command_type->value(),
            'commandType' => (string)$job->command_type->value(),
            'payload' => $this->buildCommandExecutionPayload($job, $gateway),
            'requested_at' => (string)$job->requested_at->value(),
        ];
    }

    private function buildCommandExecutionPayload(edge_gateway_command_jobs_o $job, edge_gateways_o $gateway): array
    {
        return array_merge([
            'jobId' => (int)$job->id,
            'gatewayId' => (int)$gateway->id,
            'departmentId' => (int)$gateway->department_id->value(),
            'correlationId' => (string)$job->correlation_id->value(),
        ], (array)($job->request_json->value() ?? []));
    }

    private function finalizeCommandJob(
        edge_gateway_command_jobs_o $job,
        bool $ok,
        array $payload = [],
        ?string $errorMessage = null,
        ?edge_gateways_o $gateway = null,
        ?string $terminalStatus = null
    ): void {
        $gatewayObject = $gateway ?? $this->requireGateway((int)$job->gateway_id->value());
        $response = [
            'ok' => $ok,
            'payload' => $payload,
        ];
        if (!$ok && $errorMessage !== null && trim($errorMessage) !== '') {
            $response['error'] = $errorMessage;
        }

        $job->response_json->set($response);
        $job->delivery_json->set($this->buildDeliveryMetadata(
            array_merge(
                (array)($job->delivery_json->value() ?? []),
                [
                    'delivery_channel' => ((array)($job->delivery_json->value() ?? []))['delivery_channel'] ?? self::DELIVERY_CHANNEL_API,
                    'last_dispatch_error' => $ok ? null : $errorMessage,
                ]
            ),
            self::COMMAND_EXPIRES_AFTER_SECONDS
        ));
        $job->completed_at->set($this->now());
        $job->error_message->set($ok ? null : $errorMessage);
        $job->status->set($ok ? 'COMPLETED' : $this->normalizeCommandFailureStatus($terminalStatus));

        $this->applyCommandResult($gatewayObject, $job, $ok, $payload, $errorMessage);
    }

    private function normalizeCommandFailureStatus(?string $status): string
    {
        $normalized = strtoupper(trim((string)$status));
        return in_array($normalized, ['FAILED', 'TIMED_OUT'], true) ? $normalized : 'FAILED';
    }

    private function applyCommandResult(
        edge_gateways_o $gateway,
        edge_gateway_command_jobs_o $job,
        bool $ok,
        array $payload,
        ?string $errorMessage
    ): void {
        unset($errorMessage);
        if ((string)$job->command_type->value() === 'DISCOVER_SHELLY') {
            if ($ok) {
                $inventory = isset($payload['inventory']) && is_array($payload['inventory']) ? $payload['inventory'] : [];
                $this->syncDeviceInventory((int)$gateway->id, $inventory);
                $gateway->discovery_status->set('READY');
            } else {
                $gateway->discovery_status->set('FAILED');
            }
        }
    }

    private function resolveRelayBindingLocalIp(
        int $gatewayId,
        array $binding,
        string $deviceId,
        mixed $existingLocalIp = null
    ): ?string {
        $incomingLocalIp = $this->normalizeLocalIp(
            $binding['local_ip']
            ?? $binding['localIp']
            ?? $binding['ip']
            ?? $binding['metadata']['local_ip']
            ?? null
        );
        if ($incomingLocalIp !== null) {
            return $incomingLocalIp;
        }

        $preservedLocalIp = $this->normalizeLocalIp($existingLocalIp);
        if ($preservedLocalIp !== null) {
            return $preservedLocalIp;
        }

        $inventoryLocalIp = $this->findInventoryLocalIp($gatewayId, $deviceId);
        if ($inventoryLocalIp !== null) {
            return $inventoryLocalIp;
        }

        return $this->findShellyCloudRelayLocalIp(
            $deviceId,
            trim((string)($binding['relay_id'] ?? ''))
        );
    }

    private function findInventoryLocalIp(int $gatewayId, string $deviceId): ?string
    {
        $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([
            'gateway_id' => $gatewayId,
            'device_id' => $deviceId,
            'deleted_at' => null,
        ], ['id']);

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

        $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']);
        return $this->normalizeLocalIp($inventoryObject->local_ip->value());
    }

    private function findShellyCloudRelayLocalIp(string $deviceId, string $relayId): ?string
    {
        foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) {
            $optionDeviceId = trim((string)($option['device_id'] ?? ''));
            $optionRelayId = trim((string)($option['id'] ?? ''));
            if ($optionDeviceId !== $deviceId && $optionRelayId !== $relayId && $optionRelayId !== $deviceId) {
                continue;
            }

            $localIp = $this->normalizeLocalIp(
                $option['local_ip']
                ?? $option['localIp']
                ?? $option['ip']
                ?? null
            );
            if ($localIp !== null) {
                return $localIp;
            }
        }

        return null;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function listShellyRelayOptionsForLocalIpLookup(): array
    {
        if (is_array($this->shellyRelayOptionsCache)) {
            return $this->shellyRelayOptionsCache;
        }

        if ($this->realShellyRequestsBlockedForTests()) {
            $this->shellyRelayOptionsCache = [];
            return $this->shellyRelayOptionsCache;
        }

        try {
            $this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions(true);
        } catch (\Throwable) {
            $this->shellyRelayOptionsCache = [];
        }

        return $this->shellyRelayOptionsCache;
    }

    private function realShellyRequestsBlockedForTests(): bool
    {
        return trim((string)(getenv('TRUCKWASH_TEST_BLOCK_REAL_SHELLY') ?: '')) === '1';
    }

    private function normalizeLocalIp(mixed $value): ?string
    {
        $localIp = trim((string)($value ?? ''));
        if ($localIp === '') {
            return null;
        }

        return filter_var($localIp, FILTER_VALIDATE_IP) !== false ? $localIp : null;
    }

    /**
     * @param array<string,mixed> $device
     */
    private function extractDeviceLocalIp(array $device): ?string
    {
        return $this->normalizeLocalIp(
            $device['local_ip']
            ?? $device['localIp']
            ?? $device['ip']
            ?? $device['metadata']['local_ip']
            ?? $device['metadata']['localIp']
            ?? $device['metadata']['ip']
            ?? null
        );
    }

    private function resolveRelayBindingDeviceGeneration(array $binding): ?int
    {
        $metadata = isset($binding['metadata']) && is_array($binding['metadata'])
            ? (array)$binding['metadata']
            : [];

        $generation = $this->resolveShellyDeviceGenerationFromPayload(array_merge($metadata, $binding));
        if ($generation !== null) {
            return $generation;
        }

        $inventoryDevice = $this->findInventoryDeviceForRelayBinding($binding);
        if ($inventoryDevice !== null) {
            $generation = $this->resolveShellyDeviceGenerationFromPayload($inventoryDevice);
            if ($generation !== null) {
                return $generation;
            }
        }

        $deviceId = trim((string)($binding['device_id'] ?? ''));
        $relayId = trim((string)($binding['relay_id'] ?? ''));
        foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) {
            $optionDeviceId = trim((string)($option['device_id'] ?? ''));
            $optionRelayId = trim((string)($option['id'] ?? ''));
            if ($optionDeviceId !== $deviceId && $optionRelayId !== $relayId && $optionRelayId !== $deviceId) {
                continue;
            }

            $generation = $this->resolveShellyDeviceGenerationFromPayload($option);
            if ($generation !== null) {
                return $generation;
            }
        }

        return null;
    }

    private function findInventoryDeviceForRelayBinding(array $binding): ?array
    {
        $gatewayId = (int)($binding['gateway_id'] ?? 0);
        $deviceId = trim((string)($binding['device_id'] ?? ''));
        if ($gatewayId <= 0 || $deviceId === '') {
            return null;
        }

        $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([
            'gateway_id' => $gatewayId,
            'device_id' => $deviceId,
            'deleted_at' => null,
        ], ['id']);
        if ($rows === []) {
            return null;
        }

        $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']);
        return $inventoryObject->exists() ? $inventoryObject->asArray() : null;
    }

    private function normalizeDeviceCapabilities(array $device): array
    {
        $capabilities = isset($device['capabilities']) && is_array($device['capabilities'])
            ? (array)$device['capabilities']
            : [];

        if (!isset($capabilities['generation'])) {
            $generation = $this->resolveShellyDeviceGenerationFromPayload($device);
            if ($generation !== null) {
                $capabilities['generation'] = $generation;
            }
        }

        return $capabilities;
    }

    private function resolveShellyDeviceGenerationFromPayload(array $payload): ?int
    {
        $metadata = isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [];
        $capabilities = isset($payload['capabilities']) && is_array($payload['capabilities']) ? (array)$payload['capabilities'] : [];

        foreach ([
            $payload['deviceGeneration'] ?? null,
            $payload['device_generation'] ?? null,
            $payload['generation'] ?? null,
            $payload['gen'] ?? null,
            $capabilities['generation'] ?? null,
            $metadata['deviceGeneration'] ?? null,
            $metadata['device_generation'] ?? null,
            $metadata['generation'] ?? null,
            $metadata['gen'] ?? null,
            $metadata['capabilities']['generation'] ?? null,
        ] as $candidate) {
            $generation = $this->normalizeShellyDeviceGeneration($candidate);
            if ($generation !== null) {
                return $generation;
            }
        }

        foreach ([
            $payload['device_model'] ?? null,
            $payload['deviceModel'] ?? null,
            $payload['model'] ?? null,
            $payload['device_type'] ?? null,
            $payload['deviceType'] ?? null,
            $payload['type'] ?? null,
            $payload['code'] ?? null,
            $metadata['device_model'] ?? null,
            $metadata['deviceModel'] ?? null,
            $metadata['model'] ?? null,
            $metadata['device_type'] ?? null,
            $metadata['deviceType'] ?? null,
            $metadata['type'] ?? null,
            $metadata['code'] ?? null,
        ] as $candidate) {
            $generation = $this->inferShellyDeviceGenerationFromString((string)($candidate ?? ''));
            if ($generation !== null) {
                return $generation;
            }
        }

        return null;
    }

    private function normalizeShellyDeviceGeneration(mixed $value): ?int
    {
        if (is_int($value)) {
            return $value > 0 ? $value : null;
        }

        if (is_numeric($value)) {
            $generation = (int)$value;
            return $generation > 0 ? $generation : null;
        }

        return $this->inferShellyDeviceGenerationFromString((string)($value ?? ''));
    }

    private function inferShellyDeviceGenerationFromString(string $value): ?int
    {
        $normalized = trim($value);
        if ($normalized === '') {
            return null;
        }

        if (preg_match('/\bgen(?:eration)?\s*([1-9]\d*)\b/i', $normalized, $matches) === 1) {
            return (int)$matches[1];
        }

        $upper = strtoupper($normalized);
        if (preg_match('/\bS([3-9])(?:[A-Z0-9]+)?-[A-Z0-9-]+\b/', $upper, $matches) === 1) {
            return (int)$matches[1];
        }

        if (preg_match('/\b(?:SHELLY\s+)?(?:PLUS|PRO)\b/i', $normalized) === 1
            || preg_match('/\bSP[A-Z0-9]+-[A-Z0-9-]+\b/', $upper) === 1) {
            return 2;
        }

        if (preg_match('/\bSH[A-Z0-9]+-?[A-Z0-9-]*\b/', $upper) === 1) {
            return 1;
        }

        return null;
    }

    private function backfillRelayBindingLocalIpFromInventory(int $gatewayId, string $deviceId, ?string $localIp): void
    {
        $localIp = $this->normalizeLocalIp($localIp);
        if ($localIp === null) {
            return;
        }

        $rows = (new edge_gateway_relay_bindings_o())->getFieldsWhere([
            'gateway_id' => $gatewayId,
            'device_id' => $deviceId,
            'deleted_at' => null,
        ], ['id']);

        foreach ($rows as $row) {
            $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$row['id']);
            if ($this->normalizeLocalIp($bindingObject->local_ip->value()) !== null) {
                continue;
            }

            $bindingObject->local_ip->set($localIp);
        }
    }

    /**
     * @param array<int,array<string,mixed>> $inventory
     */
    private function syncDeviceInventory(int $gatewayId, array $inventory): void
    {
        foreach ($inventory as $device) {
            $deviceId = trim((string)($device['device_id'] ?? $device['id'] ?? ''));
            if ($deviceId === '') {
                continue;
            }

            $localIp = $this->extractDeviceLocalIp($device);
            $capabilities = $this->normalizeDeviceCapabilities($device);
            $rows = (new edge_gateway_device_inventory_o())->getFieldsWhere([
                'gateway_id' => $gatewayId,
                'device_id' => $deviceId,
                'deleted_at' => null,
            ], ['id']);

            if ($rows === []) {
                (new edge_gateway_device_inventory_o())->add_object([
                    'gateway_id' => $gatewayId,
                    'device_id' => $deviceId,
                    'local_ip' => $localIp,
                    'model' => $device['model'] ?? null,
                    'channel_count' => (int)($device['channel_count'] ?? $device['channels'] ?? 1),
                    'capabilities_json' => $capabilities,
                    'online' => (bool)($device['online'] ?? true),
                    'last_seen_at' => $this->now(),
                    'metadata_json' => (array)($device['metadata'] ?? []),
                ]);
                $this->backfillRelayBindingLocalIpFromInventory($gatewayId, $deviceId, $localIp);
                continue;
            }

            $inventoryObject = (new edge_gateway_device_inventory_o())->select((int)$rows[0]['id']);
            if ($localIp !== null) {
                $inventoryObject->local_ip->set($localIp);
            }
            $inventoryObject->model->set($device['model'] ?? null);
            $inventoryObject->channel_count->set((int)($device['channel_count'] ?? $device['channels'] ?? 1));
            $inventoryObject->capabilities_json->set($capabilities);
            $inventoryObject->online->set((bool)($device['online'] ?? true));
            $inventoryObject->last_seen_at->set($this->now());
            $inventoryObject->metadata_json->set((array)($device['metadata'] ?? []));
            $this->backfillRelayBindingLocalIpFromInventory($gatewayId, $deviceId, $localIp);
        }
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function listRecentObjects(object $object, array $conditions, int $limit = 20): array
    {
        if (!method_exists($object, 'getFieldsWhere') || !method_exists($object, 'select')) {
            return [];
        }

        $rows = $object->getFieldsWhere($conditions, ['id']);
        $ids = array_map(static fn(array $row): int => (int)$row['id'], $rows);
        rsort($ids);
        $ids = array_slice($ids, 0, $limit);

        $result = [];
        foreach ($ids as $id) {
            $tmp = $object::class;
            $selected = (new $tmp())->select($id);
            if (method_exists($selected, 'asArray')) {
                $result[] = $selected->asArray();
            }
        }
        return $result;
    }

    /**
     * @throws Exception
     */
    public function validateBrokerAgentConnection(int $gatewayId, string $plainToken): array
    {
        $gateway = $this->authenticateGateway($gatewayId, $plainToken);

        return [
            'id' => (int)$gateway->id,
            'gateway_id' => (int)$gateway->id,
            'department_id' => (int)$gateway->department_id->value(),
            'label' => (string)$gateway->label->value(),
            'hostname' => $gateway->hostname->value() === null ? null : (string)$gateway->hostname->value(),
        ];
    }

    /**
     * @throws Exception
     */
    public function recordBrokerPresence(
        int $gatewayId,
        string $status,
        ?string $connectionId = null,
        ?string $reason = null,
        array $metadata = []
    ): array {
        $gateway = $this->requireGateway($gatewayId);
        $normalizedStatus = trim(strtolower($status));
        $connected = $normalizedStatus === 'connected';
        $presence = [
            'gateway_id' => $gatewayId,
            'connected' => $connected,
            'connection_id' => $connectionId,
            'last_seen_at' => $this->now(),
            'disconnect_reason' => $connected ? null : $reason,
            'last_error' => !$connected && $reason !== null && trim($reason) !== '' ? trim($reason) : null,
            'metadata' => $metadata,
        ];

        $this->writeBrokerPresence($gatewayId, $presence);

        $gatewayMetadata = (array)($gateway->metadata_json->value() ?? []);
        $gatewayMetadata['broker_presence'] = array_merge(
            (array)($gatewayMetadata['broker_presence'] ?? []),
            $presence
        );
        $gatewayMetadata['broker_connected'] = $connected;
        if ($connected) {
            $gatewayMetadata['broker_connected_at'] = $this->now();
            $gatewayMetadata['broker_last_error'] = null;
        } else {
            $gatewayMetadata['broker_disconnected_at'] = $this->now();
            $gatewayMetadata['broker_last_error'] = $reason;
        }
        $gateway->metadata_json->set($gatewayMetadata);

        edge_gateway_view_cache::syncGateway($this->getGateway($gatewayId));

        return $presence;
    }

    private function readBrokerPresence(int $gatewayId): array
    {
        $redisPresence = $this->withRedis(
            static fn(redis $redis): ?string => $redis->get(edge_gateway_manager::brokerPresenceKey($gatewayId)),
            null
        );
        if (is_string($redisPresence) && trim($redisPresence) !== '') {
            $decoded = json_decode($redisPresence, true);
            if (is_array($decoded)) {
                return $decoded;
            }
        }

        $gateway = (new edge_gateways_o())->select($gatewayId);
        if ($gateway->exists()) {
            $metadata = (array)($gateway->metadata_json->value() ?? []);
            if (isset($metadata['broker_presence']) && is_array($metadata['broker_presence'])) {
                return (array)$metadata['broker_presence'];
            }
        }

        return [];
    }

    private function writeBrokerPresence(int $gatewayId, array $presence): void
    {
        $encoded = json_encode($presence, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($encoded === false) {
            return;
        }

        $this->withRedis(
            static function (redis $redis) use ($gatewayId, $encoded): void {
                $redis->setEx(edge_gateway_manager::brokerPresenceKey($gatewayId), $encoded, edge_gateway_manager::BROKER_PRESENCE_TTL_SECONDS);
            }
        );
    }

    private static function brokerPresenceKey(int $gatewayId): string
    {
        return 'edge_gateway_broker_presence_' . $gatewayId;
    }

    private function withRedis(callable $callback, mixed $fallback = null): mixed
    {
        try {
            return $callback(new redis());
        } catch (\Throwable) {
            return $fallback;
        }
    }

    private function buildGatewayOperationalSnapshot(int $gatewayId): array
    {
        $pdo = db::getPDO();

        $counts = [
            'command_backlog' => 0,
            'operation_backlog' => 0,
            'stale_dispatching_commands' => 0,
            'recent_command_failures' => 0,
            'recent_command_timeouts' => 0,
            'recent_unknown_gate_outcomes' => 0,
            'recent_relay_commands' => 0,
            'recent_command_avg_latency_seconds' => null,
            'last_successful_command_at' => null,
            'last_successful_discovery_at' => null,
            'last_successful_operation_at' => null,
            'last_successful_update_at' => null,
        ];

        $countQueries = [
            'command_backlog' => "SELECT COUNT(*) AS c
                FROM edge_gateway_command_jobs
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status IN ('PENDING', 'DISPATCHING')",
            'operation_backlog' => "SELECT COUNT(*) AS c
                FROM edge_gateway_operations
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status IN ('PENDING', 'IN_PROGRESS')",
            'stale_dispatching_commands' => "SELECT COUNT(*) AS c
                FROM edge_gateway_command_jobs
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status = 'DISPATCHING'
                  AND COALESCE(updated_at, created_at, requested_at) <= :stale_before",
            'recent_command_failures' => "SELECT COUNT(*) AS c
                FROM edge_gateway_command_jobs
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status IN ('FAILED', 'TIMED_OUT')
                  AND requested_at >= :recent_cutoff",
            'recent_command_timeouts' => "SELECT COUNT(*) AS c
                FROM edge_gateway_command_jobs
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status = 'TIMED_OUT'
                  AND requested_at >= :recent_cutoff",
            'recent_unknown_gate_outcomes' => "SELECT COUNT(*) AS c
                FROM edge_gateway_command_jobs
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status = 'TIMED_OUT'
                  AND requested_at >= :recent_cutoff
                  AND COALESCE(JSON_UNQUOTE(JSON_EXTRACT(delivery_json, '$.no_auto_retry')), 'false') = 'true'",
            'recent_relay_commands' => "SELECT COUNT(*) AS c
                FROM edge_gateway_command_jobs
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND command_type IN ('GET_RELAY_STATUS', 'SET_RELAY_STATE')
                  AND requested_at >= :recent_cutoff",
        ];

        foreach ($countQueries as $key => $sql) {
            $statement = $pdo->prepare($sql);
            $params = [
                ':gateway_id' => $gatewayId,
            ];
            if (str_contains($sql, ':stale_before')) {
                $params[':stale_before'] = $this->formatDateTime(time() - self::COMMAND_DISPATCH_STALE_AFTER_SECONDS);
            }
            if (str_contains($sql, ':recent_cutoff')) {
                $params[':recent_cutoff'] = $this->formatDateTime(time() - self::RELAY_OPERATIONAL_WINDOW_SECONDS);
            }
            $statement->execute($params);
            $row = $statement->fetch();
            $counts[$key] = isset($row['c']) ? (int)$row['c'] : 0;
        }

        $counts['recent_relay_failure_rate'] = $counts['recent_relay_commands'] > 0
            ? round($counts['recent_command_failures'] / $counts['recent_relay_commands'], 4)
            : 0.0;

        $latencyStatement = $pdo->prepare(
            "SELECT AVG(TIMESTAMPDIFF(SECOND, requested_at, completed_at)) AS avg_latency
             FROM edge_gateway_command_jobs
             WHERE gateway_id = :gateway_id
               AND deleted_at IS NULL
               AND completed_at IS NOT NULL
               AND requested_at >= :recent_cutoff"
        );
        $latencyStatement->execute([
            ':gateway_id' => $gatewayId,
            ':recent_cutoff' => $this->formatDateTime(time() - self::RELAY_OPERATIONAL_WINDOW_SECONDS),
        ]);
        $latencyRow = $latencyStatement->fetch();
        if (is_array($latencyRow) && $latencyRow['avg_latency'] !== null) {
            $counts['recent_command_avg_latency_seconds'] = round((float)$latencyRow['avg_latency'], 2);
        }

        $timestampQueries = [
            'last_successful_command_at' => "SELECT completed_at AS ts
                FROM edge_gateway_command_jobs
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status = 'COMPLETED'
                ORDER BY completed_at DESC, id DESC
                LIMIT 1",
            'last_successful_discovery_at' => "SELECT completed_at AS ts
                FROM edge_gateway_command_jobs
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status = 'COMPLETED'
                  AND command_type = 'DISCOVER_SHELLY'
                ORDER BY completed_at DESC, id DESC
                LIMIT 1",
            'last_successful_operation_at' => "SELECT completed_at AS ts
                FROM edge_gateway_operations
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status = 'COMPLETED'
                ORDER BY completed_at DESC, id DESC
                LIMIT 1",
            'last_successful_update_at' => "SELECT completed_at AS ts
                FROM edge_gateway_operations
                WHERE gateway_id = :gateway_id
                  AND deleted_at IS NULL
                  AND status = 'COMPLETED'
                  AND type = 'UPDATE'
                ORDER BY completed_at DESC, id DESC
                LIMIT 1",
        ];

        foreach ($timestampQueries as $key => $sql) {
            $statement = $pdo->prepare($sql);
            $statement->execute([':gateway_id' => $gatewayId]);
            $row = $statement->fetch();
            $counts[$key] = isset($row['ts']) ? (string)$row['ts'] : null;
        }

        return $counts;
    }

    private function buildBrokerPublicUrl(): ?string
    {
        $configured = $this->configuredPublicBrokerUrl();
        if ($configured !== '') {
            return rtrim($configured, '/');
        }

        $apiBaseUrl = $this->getApiBaseUrl();
        if (trim($apiBaseUrl) === '') {
            return null;
        }

        return rtrim($apiBaseUrl, '/') . '/edge-broker';
    }

    private function buildBrokerPublicWebSocketUrl(string $path = ''): ?string
    {
        $brokerUrl = $this->buildBrokerPublicUrl();
        if ($brokerUrl === null) {
            return null;
        }

        $parsed = parse_url($brokerUrl);
        $scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'wss' : 'ws';
        $host = (string)($parsed['host'] ?? '');
        if ($host === '') {
            return null;
        }

        $port = isset($parsed['port']) ? ':' . (int)$parsed['port'] : '';
        $basePath = rtrim((string)($parsed['path'] ?? ''), '/');
        $normalizedPath = '/' . ltrim($path, '/');
        $fullPath = $basePath . ($normalizedPath === '/' ? '' : $normalizedPath);

        return $scheme . '://' . $host . $port . ($fullPath === '' ? '' : $fullPath);
    }

    private function buildBrokerInternalUrl(): ?string
    {
        $configured = $this->configuredBrokerInternalUrl();
        return $configured !== '' ? rtrim($configured, '/') : null;
    }

    private function configuredPublicBrokerUrl(): string
    {
        try {
            $configured = trim((string)(new edgegateway())->publicBrokerUrl());
            if ($configured !== '') {
                return rtrim($configured, '/');
            }
        } catch (Exception) {
        }

        $fallback = trim((string)(getenv('EDGE_PUBLIC_BROKER_URL') ?: ''));
        return $fallback !== '' ? rtrim($fallback, '/') : '';
    }

    private function configuredBrokerInternalUrl(): string
    {
        try {
            $configured = trim((string)(new edgegateway())->brokerUrl());
            if ($configured !== '') {
                return rtrim($configured, '/');
            }
        } catch (Exception) {
        }

        $fallback = trim((string)(getenv('EDGE_BROKER_URL') ?: ''));
        return $fallback !== '' ? rtrim($fallback, '/') : '';
    }

    private function configuredBrokerAuthMode(): string
    {
        try {
            $configured = trim((string)(new edgegateway())->brokerAuthMode());
            return $configured !== '' ? $configured : 'manager';
        } catch (Exception) {
        }

        $fallback = trim((string)(getenv('EDGE_AUTH_MODE') ?: ''));
        return $fallback !== '' ? $fallback : 'manager';
    }

    private function configuredBrokerSharedSecret(): string
    {
        try {
            $configured = trim((string)(new edgegateway())->brokerSharedSecret());
            if ($configured !== '') {
                return $configured;
            }
        } catch (Exception) {
        }

        return trim((string)(getenv('EDGE_BROKER_SHARED_SECRET') ?: ''));
    }

    private function resolveGatewayPreferredCommandChannel(edge_gateways_o|array $gateway): string
    {
        $gatewayId = is_array($gateway) ? (int)($gateway['id'] ?? 0) : (int)$gateway->id;
        if ($gatewayId <= 0 || $this->buildBrokerInternalUrl() === null) {
            return self::DELIVERY_CHANNEL_API;
        }

        $presence = $this->readBrokerPresence($gatewayId);
        return self::isBrokerPresenceConnected($presence) ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API;
    }

    public function validateBrokerSharedSecret(?string $secret): bool
    {
        $configured = $this->configuredBrokerSharedSecret();
        if ($configured === '') {
            return false;
        }

        return $secret !== null && hash_equals($configured, trim($secret));
    }

    /**
     * @param array<string,mixed> $options
     * @return array<string,mixed>
     */
    public function diagnoseBrokerConfiguration(array $options = []): array
    {
        $target = strtolower(trim((string)($options['target'] ?? 'all')));
        $target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all';

        $internalUrl = $this->normalizeBrokerDiagnosticBaseUrl($this->configuredBrokerInternalUrl());
        $publicConfigured = $this->configuredPublicBrokerUrl();
        $publicUrl = $this->normalizeBrokerDiagnosticBaseUrl(
            $publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl()
        );
        $sharedSecret = array_key_exists('broker_shared_secret', $options)
            ? trim((string)$options['broker_shared_secret'])
            : $this->configuredBrokerSharedSecret();

        $diagnostics = [
            'target' => $target,
            'checked_at' => $this->now(),
            'broker_auth_mode' => array_key_exists('broker_auth_mode', $options)
                ? trim((string)$options['broker_auth_mode'])
                : $this->configuredBrokerAuthMode(),
            'broker_shared_secret_configured' => $sharedSecret !== '',
        ];

        if ($target === 'internal' || $target === 'all') {
            $diagnostics['internal_broker_connection'] = $this->diagnoseBrokerHttpEndpoint(
                $internalUrl,
                'Internal broker'
            );
        }

        if ($target === 'public' || $target === 'all') {
            $diagnostics['public_broker_url'] = $this->diagnoseBrokerHttpEndpoint(
                $publicUrl,
                'Public broker'
            );
        }

        if ($target === 'secret' || $target === 'all') {
            $diagnostics['broker_shared_secret'] = $this->diagnoseBrokerSharedSecret(
                $internalUrl,
                $sharedSecret
            );
        }

        return $diagnostics;
    }

    private function deriveBrokerPublicUrl(): ?string
    {
        $apiBaseUrl = $this->getApiBaseUrl();
        if (trim($apiBaseUrl) === '') {
            return null;
        }

        return rtrim($apiBaseUrl, '/') . '/edge-broker';
    }

    /**
     * @return array{url: ?string, error: ?string}
     */
    private function normalizeBrokerDiagnosticBaseUrl(mixed $value): array
    {
        $url = trim((string)$value);
        if ($url === '') {
            return [
                'url' => null,
                'error' => 'not_configured',
            ];
        }

        $parsed = parse_url($url);
        $scheme = is_array($parsed) ? strtolower((string)($parsed['scheme'] ?? '')) : '';
        $host = is_array($parsed) ? trim((string)($parsed['host'] ?? '')) : '';
        if (!is_array($parsed) || $host === '' || !in_array($scheme, ['http', 'https'], true)) {
            return [
                'url' => $url,
                'error' => 'invalid_url',
            ];
        }

        return [
            'url' => rtrim($url, '/'),
            'error' => null,
        ];
    }

    /**
     * @param array{url: ?string, error: ?string} $baseUrl
     * @return array<string,mixed>
     */
    private function diagnoseBrokerHttpEndpoint(array $baseUrl, string $label): array
    {
        if ($baseUrl['url'] === null || $baseUrl['error'] !== null) {
            return $this->brokerDiagnosticUrlFailure($baseUrl, $label);
        }

        $health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health');
        if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) {
            return array_merge($this->redactBrokerDiagnosticProbe($health), [
                'ok' => true,
                'status' => 'connected',
                'url' => $baseUrl['url'],
                'message' => $label . ' responded to the health check.',
            ]);
        }

        if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) {
            return array_merge($this->redactBrokerDiagnosticProbe($health), [
                'ok' => true,
                'status' => 'connected_legacy',
                'url' => $baseUrl['url'],
                'message' => $label . ' responded, but the health endpoint is not deployed yet.',
            ]);
        }

        if (($health['status_code'] ?? null) !== null) {
            return array_merge($this->redactBrokerDiagnosticProbe($health), [
                'ok' => false,
                'status' => 'unexpected_response',
                'url' => $baseUrl['url'],
                'message' => $label . ' returned HTTP ' . (string)$health['status_code'] . ' instead of the broker health response.',
            ]);
        }

        return array_merge($this->redactBrokerDiagnosticProbe($health), [
            'ok' => false,
            'status' => 'unreachable',
            'url' => $baseUrl['url'],
            'message' => $label . ' did not respond.',
        ]);
    }

    /**
     * @param array{url: ?string, error: ?string} $baseUrl
     * @return array<string,mixed>
     */
    private function diagnoseBrokerSharedSecret(array $baseUrl, string $sharedSecret): array
    {
        if ($baseUrl['url'] === null || $baseUrl['error'] !== null) {
            return $this->brokerDiagnosticUrlFailure($baseUrl, 'Internal broker');
        }

        $headers = $sharedSecret !== '' ? ['x-edge-broker-secret: ' . $sharedSecret] : [];
        $diagnostic = $this->brokerHttpProbe(
            $baseUrl['url'] . '/api/diagnostics/shared-secret',
            'POST',
            [],
            $headers
        );

        if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) {
            $required = (bool)($diagnostic['json']['shared_secret_required'] ?? false);
            return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
                'ok' => true,
                'status' => $required ? 'validated' : 'not_required',
                'url' => $baseUrl['url'],
                'message' => $required
                    ? 'Broker accepted the configured shared secret.'
                    : 'Broker responded and does not currently require a shared secret.',
            ]);
        }

        if (($diagnostic['status_code'] ?? null) === 403) {
            return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
                'ok' => false,
                'status' => 'secret_rejected',
                'url' => $baseUrl['url'],
                'message' => 'Broker rejected the configured shared secret.',
            ]);
        }

        if (($diagnostic['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($diagnostic)) {
            return $this->diagnoseBrokerSharedSecretWithLegacySync($baseUrl, $headers);
        }

        if (($diagnostic['status_code'] ?? null) !== null) {
            return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
                'ok' => false,
                'status' => 'unexpected_response',
                'url' => $baseUrl['url'],
                'message' => 'Broker returned HTTP ' . (string)$diagnostic['status_code'] . ' during shared secret validation.',
            ]);
        }

        return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
            'ok' => false,
            'status' => 'unreachable',
            'url' => $baseUrl['url'],
            'message' => 'Internal broker did not respond during shared secret validation.',
        ]);
    }

    /**
     * @param array{url: ?string, error: ?string} $baseUrl
     * @param array<int,string> $headers
     * @return array<string,mixed>
     */
    private function diagnoseBrokerSharedSecretWithLegacySync(array $baseUrl, array $headers): array
    {
        $legacy = $this->brokerHttpProbe(
            $baseUrl['url'] . '/api/gateways/0/sync',
            'POST',
            ['diagnostic' => true],
            $headers
        );

        if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) {
            return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
                'ok' => true,
                'status' => 'validated_legacy',
                'url' => $baseUrl['url'],
                'message' => 'Broker accepted the shared secret through the legacy sync endpoint.',
            ]);
        }

        if (($legacy['status_code'] ?? null) === 403) {
            return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
                'ok' => false,
                'status' => 'secret_rejected',
                'url' => $baseUrl['url'],
                'message' => 'Broker rejected the configured shared secret.',
            ]);
        }

        return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
            'ok' => false,
            'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response',
            'url' => $baseUrl['url'],
            'message' => 'Broker shared secret could not be validated.',
        ]);
    }

    /**
     * @param array<string,mixed> $probe
     * @return array<string,mixed>
     */
    private function redactBrokerDiagnosticProbe(array $probe): array
    {
        unset($probe['json']);
        $probe['body_excerpt'] = null;
        return $probe;
    }

    /**
     * @param array{url: ?string, error: ?string} $baseUrl
     * @return array<string,mixed>
     */
    private function brokerDiagnosticUrlFailure(array $baseUrl, string $label): array
    {
        $error = (string)($baseUrl['error'] ?? 'not_configured');
        return [
            'ok' => false,
            'status' => $error,
            'url' => $baseUrl['url'],
            'status_code' => null,
            'elapsed_ms' => 0,
            'message' => $error === 'invalid_url'
                ? $label . ' URL is not a valid http(s) URL.'
                : $label . ' URL is not configured.',
        ];
    }

    /**
     * @param array<string,mixed> $response
     */
    private function isBrokerNotFoundProbe(array $response): bool
    {
        $json = isset($response['json']) && is_array($response['json']) ? (array)$response['json'] : [];
        return strtolower(trim((string)($json['error'] ?? ''))) === 'not found';
    }

    /**
     * @param array<int,string> $headers
     * @return array<string,mixed>
     */
    private function brokerHttpProbe(
        string $url,
        string $method = 'GET',
        ?array $payload = null,
        array $headers = [],
        int $timeoutSeconds = 3
    ): array {
        $method = strtoupper(trim($method)) ?: 'GET';
        $requestHeaders = array_filter(array_merge(['Accept: application/json'], $headers));
        $options = [
            'method' => $method,
            'header' => implode("\r\n", $requestHeaders),
            'timeout' => max(1, $timeoutSeconds),
            'ignore_errors' => true,
        ];

        if ($payload !== null) {
            $requestHeaders[] = 'Content-Type: application/json';
            $options['header'] = implode("\r\n", array_filter($requestHeaders));
            $options['content'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        }

        $context = stream_context_create(['http' => $options]);
        $started = microtime(true);
        $body = @file_get_contents($url, false, $context);
        $elapsedMs = (int)round((microtime(true) - $started) * 1000);
        $responseHeaders = is_array($http_response_header ?? null) ? $http_response_header : [];
        $statusCode = $this->parseHttpStatusCode($responseHeaders);

        if ($body === false) {
            $lastError = error_get_last();
            return [
                'status_code' => $statusCode,
                'elapsed_ms' => $elapsedMs,
                'error' => isset($lastError['message']) ? self::trimInstallSessionText($lastError['message'], 512) : null,
                'json' => null,
                'body_excerpt' => null,
            ];
        }

        $decoded = json_decode($body, true);
        return [
            'status_code' => $statusCode,
            'elapsed_ms' => $elapsedMs,
            'error' => null,
            'json' => is_array($decoded) ? $decoded : null,
            'body_excerpt' => null,
        ];
    }

    /**
     * @param array<int,string> $headers
     */
    private function parseHttpStatusCode(array $headers): ?int
    {
        foreach ($headers as $header) {
            if (preg_match('/^HTTP\/\S+\s+(\d{3})\b/i', trim((string)$header), $matches) === 1) {
                return (int)$matches[1];
            }
        }

        return null;
    }

    /**
     * @throws Exception
     */
    public function buildBrokerBacklog(int $gatewayId, ?string $agentInstanceId = null): array
    {
        $gateway = $this->requireGateway($gatewayId);
        $operations = new edge_gateway_operation_service($this);
        $dispatch = [];

        $operation = $operations->claimBrokerOperation($gatewayId, $agentInstanceId);
        if ($operation !== null) {
            $dispatch[] = [
                'type' => 'TASK_DISPATCH',
                'taskType' => 'OPERATION',
                'operation' => $operation,
            ];
        }

        foreach ($operations->listBrokerCancellationRequests($gatewayId, $agentInstanceId) as $cancelledOperation) {
            $dispatch[] = [
                'type' => 'TASK_CANCEL',
                'taskType' => 'OPERATION',
                'operation' => $cancelledOperation,
            ];
        }

        return [
            'gateway' => [
                'id' => (int)$gateway->id,
                'department_id' => (int)$gateway->department_id->value(),
                'label' => (string)$gateway->label->value(),
            ],
            'dispatch' => $dispatch,
        ];
    }

    public function notifyBrokerGatewaySync(int $gatewayId): void
    {
        $brokerUrl = $this->buildBrokerInternalUrl();
        if ($brokerUrl === null) {
            return;
        }

        try {
            $this->httpJsonRequest(
                $brokerUrl . '/api/gateways/' . $gatewayId . '/sync',
                ['gatewayId' => $gatewayId],
                ['x-edge-broker-secret: ' . $this->configuredBrokerSharedSecret()],
                self::BROKER_HTTP_TIMEOUT_SECONDS
            );
        } catch (Exception) {
            // Broker sync is opportunistic. Legacy polling remains available during rollout.
        }
    }

    private function normalizeRelayBindingMetadata(array $metadata = [], array $binding = []): array
    {
        $fallbackMode = strtoupper(trim((string)($binding['fallback_mode'] ?? $metadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL)));
        if (!in_array($fallbackMode, [
            self::RELAY_FALLBACK_PREFER_LOCAL,
            self::RELAY_FALLBACK_LOCAL_ONLY,
            self::RELAY_FALLBACK_CLOUD_ONLY,
        ], true)) {
            $fallbackMode = self::RELAY_FALLBACK_PREFER_LOCAL;
        }

        $metadata['fallback_mode'] = $fallbackMode;
        $consumerContexts = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
        if (!is_array($consumerContexts)) {
            $consumerContexts = [];
        }
        $consumerContexts = array_values(array_filter(array_map(static function (mixed $consumer): ?array {
            if (!is_array($consumer)) {
                return null;
            }

            $consumerType = trim((string)($consumer['type'] ?? ''));
            if ($consumerType === '') {
                return null;
            }

            return [
                'type' => $consumerType,
                'id' => isset($consumer['id']) ? (int)$consumer['id'] : null,
                'slot' => isset($consumer['slot']) ? (string)$consumer['slot'] : null,
                'label' => isset($consumer['label']) ? (string)$consumer['label'] : null,
            ];
        }, $consumerContexts)));
        $metadata['consumer_contexts'] = $consumerContexts;
        $metadata['consumers'] = $consumerContexts;

        foreach (['device_type', 'device_model', 'code'] as $field) {
            $value = trim((string)($binding[$field] ?? $metadata[$field] ?? ''));
            if ($value !== '') {
                $metadata[$field] = $value;
            }
        }

        $generation = $this->normalizeShellyDeviceGeneration(
            $binding['deviceGeneration']
            ?? $binding['device_generation']
            ?? $binding['generation']
            ?? $metadata['deviceGeneration']
            ?? $metadata['device_generation']
            ?? $metadata['generation']
            ?? null
        );
        if ($generation !== null) {
            $metadata['device_generation'] = $generation;
            $metadata['generation'] = $generation;
        }

        if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) {
            $metadata['last_resolution'] = (array)$binding['last_resolution'];
        }
        if (array_key_exists('last_success_at', $binding)) {
            $metadata['last_success_at'] = $binding['last_success_at'];
        }
        if (array_key_exists('last_error', $binding)) {
            $metadata['last_error'] = $binding['last_error'];
        }

        return $metadata;
    }

    private function buildDeliveryMetadata(array $overrides = [], int $ttlSeconds = self::COMMAND_EXPIRES_AFTER_SECONDS): array
    {
        $preferredChannel = strtoupper(trim((string)($overrides['preferred_channel'] ?? self::DELIVERY_CHANNEL_API)));
        if (!in_array($preferredChannel, [
            self::DELIVERY_CHANNEL_BROKER,
            self::DELIVERY_CHANNEL_API,
            self::DELIVERY_CHANNEL_CLOUD,
        ], true)) {
            $preferredChannel = self::DELIVERY_CHANNEL_API;
        }

        return array_merge([
            'preferred_channel' => $preferredChannel,
            'delivery_channel' => $overrides['delivery_channel'] ?? null,
            'attempt_count' => isset($overrides['attempt_count']) ? (int)$overrides['attempt_count'] : 0,
            'expires_at' => $overrides['expires_at'] ?? $this->formatDateTime(time() + max(30, $ttlSeconds)),
            'fallback_reason' => $overrides['fallback_reason'] ?? null,
            'last_dispatch_error' => $overrides['last_dispatch_error'] ?? null,
        ], $overrides);
    }

    private function resolveRelayExecutionPlan(edge_gateways_o $gateway, array $binding, string $logicalRelayId): array
    {
        $gatewayData = $gateway->asArray();
        $gatewayData['metadata']['broker_presence'] = $this->readBrokerPresence((int)$gateway->id);
        $gatewayData['department_transport_mode'] = $this->getDepartmentTransportMode((int)$gateway->department_id->value());
        $gatewayData['inventory'] = $this->listInventory((int)$gateway->id);
        $gatewayData['bindings'] = [$binding];

        $runtime = self::deriveGatewayRuntimeState($gatewayData);
        $relayHealth = (array)($runtime['relay_health'][0] ?? []);
        $executionPath = (string)($relayHealth['execution_path'] ?? 'local');

        return array_merge($relayHealth, [
            'relay_id' => $logicalRelayId,
            'execution_path' => $executionPath,
            'preferred_channel' => $executionPath === 'local'
                ? $this->resolveGatewayPreferredCommandChannel($gateway)
                : self::DELIVERY_CHANNEL_CLOUD,
        ]);
    }

    private function forceLocalRelayExecutionPlan(array $resolution): array
    {
        $wasCloud = (string)($resolution['execution_path'] ?? 'local') === 'cloud';
        $recoveryActions = array_values(array_filter(array_unique(array_merge(
            (array)($resolution['recovery_actions'] ?? []),
            ['retry_local_command']
        ))));

        return array_merge($resolution, [
            'execution_path' => 'local',
            'preferred_channel' => self::DELIVERY_CHANNEL_BROKER,
            'fallback_mode' => self::RELAY_FALLBACK_LOCAL_ONLY,
            'reason' => $wasCloud ? 'local_transport_override' : ($resolution['reason'] ?? null),
            'recommended_action' => $resolution['recommended_action'] ?? 'retry_local_command',
            'recovery_actions' => $recoveryActions,
        ]);
    }

    private function normalizeRelayToggleAfter(?int $toggleAfterSeconds): ?int
    {
        if ($toggleAfterSeconds === null || $toggleAfterSeconds <= 0) {
            return null;
        }

        return $toggleAfterSeconds;
    }

    private function currentRelayActionContext(): array
    {
        $context = [];
        foreach (self::$relayActionContextStack as $entry) {
            if (is_array($entry)) {
                $context = array_replace_recursive($context, $entry);
            }
        }

        return $context;
    }

    private function normalizeRelayActionContext(array $context): array
    {
        $context = array_replace_recursive($this->currentRelayActionContext(), $context);
        $module = trim((string)($context['module'] ?? $context['module_responsible'] ?? ''));
        $reason = trim((string)($context['reason'] ?? $context['action_reason'] ?? ''));
        $context['module'] = $module !== '' ? $module : 'edge_gateway';
        $context['module_responsible'] = $context['module'];
        $context['reason'] = $reason !== '' ? $reason : 'Relay dispatch';

        $actor = array_replace(
            $this->resolveRelayAuthenticatedActorContext(),
            isset($context['actor']) && is_array($context['actor']) ? (array)$context['actor'] : []
        );
        foreach (['user_id', 'admin_user_id', 'customer_user_id', 'customer_number', 'subuser_id', 'type', 'display_name'] as $key) {
            if (array_key_exists($key, $context) && !array_key_exists($key, $actor)) {
                $actor[$key] = $context[$key];
            }
        }
        $context['actor'] = $this->compactRelayLogArray($actor);

        $associated = isset($context['associated']) && is_array($context['associated'])
            ? (array)$context['associated']
            : [];
        foreach (['admin_user_id', 'customer_user_id', 'customer_number', 'subuser_id'] as $key) {
            if (isset($context['actor'][$key]) && !isset($associated[$key])) {
                $associated[$key] = $context['actor'][$key];
            }
            if (array_key_exists($key, $context) && !isset($associated[$key])) {
                $associated[$key] = $context[$key];
            }
        }
        $context['associated'] = $this->compactRelayLogArray($associated);

        return $this->compactRelayLogArray($context);
    }

    private function resolveRelayAuthenticatedActorContext(): array
    {
        if (!function_exists('getallheaders')) {
            return [];
        }

        try {
            $headers = getallheaders();
        } catch (\Throwable) {
            return [];
        }
        if (!is_array($headers) || $this->relayHeaderValue($headers, 'Authorization') === null) {
            return [];
        }

        try {
            $user = (new authentication())->get_user();
        } catch (\Throwable) {
            return [];
        }

        if (!$user instanceof \objects\users_o || !$user->exists()) {
            return [];
        }

        $userId = (int)$user->id;
        $customerNumber = isset($user->customer_number)
            ? (int)($user->customer_number->value() ?? 0)
            : 0;
        $displayName = isset($user->display_name) ? trim((string)($user->display_name->value() ?? '')) : '';
        $actor = [
            'user_id' => $userId,
            'type' => $customerNumber > 0 ? 'customer' : 'admin',
        ];
        if ($displayName !== '') {
            $actor['display_name'] = $displayName;
        }
        if ($customerNumber > 0) {
            $actor['customer_user_id'] = $userId;
            $actor['customer_number'] = $customerNumber;
        } else {
            $actor['admin_user_id'] = $userId;
        }

        return $actor;
    }

    private function relayHeaderValue(array $headers, string $name): ?string
    {
        $normalized = strtolower($name);
        foreach ($headers as $key => $value) {
            if (strtolower((string)$key) === $normalized) {
                $value = trim((string)$value);
                return $value !== '' ? $value : null;
            }
        }

        return null;
    }

    private function resolveRelayRequestedBy(array $actionContext): ?int
    {
        $actor = isset($actionContext['actor']) && is_array($actionContext['actor']) ? (array)$actionContext['actor'] : [];
        foreach (['admin_user_id', 'user_id', 'customer_user_id'] as $key) {
            $id = isset($actor[$key]) ? (int)$actor[$key] : 0;
            if ($id > 0) {
                return $id;
            }
        }

        return null;
    }

    private function buildRelayCommandSignal(edge_gateway_command_jobs_o $job, array $request): array
    {
        $delivery = (array)($job->delivery_json->value() ?? []);
        return $this->compactRelayLogArray([
            'command_type' => (string)$job->command_type->value(),
            'job_id' => (int)$job->id,
            'correlation_id' => (string)$job->correlation_id->value(),
            'request' => $request,
            'preferred_channel' => $delivery['preferred_channel'] ?? null,
            'require_fast_path' => !empty($delivery['require_fast_path']),
            'fallback_reason' => $delivery['fallback_reason'] ?? null,
        ]);
    }

    private function appendRelayDispatchLog(
        array $binding,
        array $resolution,
        bool $success,
        array $dispatchLog,
        array $result = [],
        ?\Throwable $exception = null
    ): ?array {
        try {
            $gatewayId = (int)($binding['gateway_id'] ?? 0);
            if ($gatewayId <= 0) {
                return null;
            }

            $context = $this->buildRelayLogContext($binding, $resolution, $success, $dispatchLog, $result, $exception);
            return $this->appendGatewayLogEntry(
                $gatewayId,
                $this->buildRelayLogMessage($context, $success),
                $success ? 'INFO' : 'ERROR',
                'relay',
                'RELAY_DISPATCH',
                $context
            );
        } catch (\Throwable) {
            return null;
        }
    }

    private function buildRelayLogContext(
        array $binding,
        array $resolution,
        bool $success,
        array $dispatchLog,
        array $result,
        ?\Throwable $exception
    ): array {
        $actionContext = $this->normalizeRelayActionContext(
            isset($dispatchLog['action_context']) && is_array($dispatchLog['action_context'])
                ? (array)$dispatchLog['action_context']
                : []
        );
        $handler = strtolower(trim((string)($dispatchLog['handler'] ?? $resolution['execution_path'] ?? 'local')));
        $handler = $handler === 'cloud' ? 'cloud' : 'local';
        $deliveryChannel = (string)($resolution['delivery_channel']
            ?? ($handler === 'cloud' ? self::DELIVERY_CHANNEL_CLOUD : ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API)));
        $relayId = trim((string)($dispatchLog['relay_id'] ?? $binding['relay_id'] ?? ''));
        $signal = isset($dispatchLog['signal']) && is_array($dispatchLog['signal'])
            ? (array)$dispatchLog['signal']
            : [];
        if ($relayId !== '' && !isset($signal['relay_id'])) {
            $signal['relay_id'] = $relayId;
        }
        $relayRole = $this->normalizeRelayLogRole(
            $actionContext['relay_role']
                ?? $actionContext['role']
                ?? $binding['metadata']['relay_role']
                ?? $binding['metadata']['role']
                ?? null
        );
        $relayName = $this->resolveRelayLogDisplayName($binding, $actionContext, $signal);

        $context = [
            'success' => $success,
            'module' => (string)$actionContext['module'],
            'module_responsible' => (string)$actionContext['module_responsible'],
            'reason' => (string)$actionContext['reason'],
            'handler' => $handler,
            'execution_path' => $handler,
            'delivery_channel' => $deliveryChannel,
            'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null,
            'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null,
            'relay_id' => $relayId !== '' ? $relayId : null,
            'relay_name' => $relayName,
            'relay_role' => $relayRole,
            'action' => strtoupper(trim((string)($dispatchLog['action'] ?? 'RELAY'))),
            'target_on' => array_key_exists('target_on', $dispatchLog) ? $dispatchLog['target_on'] : null,
            'toggle_after_seconds' => $dispatchLog['toggle_after_seconds'] ?? null,
            'actor' => isset($actionContext['actor']) && is_array($actionContext['actor']) ? (array)$actionContext['actor'] : [],
            'associated' => isset($actionContext['associated']) && is_array($actionContext['associated']) ? (array)$actionContext['associated'] : [],
            'binding' => $this->relayLogBindingPayload($binding),
            'execution' => $this->compactRelayLogArray([
                'path' => $handler,
                'channel' => $deliveryChannel,
                'reason' => $resolution['reason'] ?? null,
                'fallback_reason' => $resolution['fallback_reason'] ?? null,
                'fallback_mode' => $resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL,
                'recommended_action' => $resolution['recommended_action'] ?? null,
            ]),
            'signal' => $this->sanitizeRelayLogValue($signal),
            'response' => $this->relayLogResponsePayload($result),
        ];

        if ($exception !== null) {
            $context['error'] = [
                'message' => $exception->getMessage(),
                'type' => $exception::class,
            ];
        }

        $extraContext = $actionContext;
        unset(
            $extraContext['module'],
            $extraContext['module_responsible'],
            $extraContext['reason'],
            $extraContext['actor'],
            $extraContext['associated'],
            $extraContext['admin_user_id'],
            $extraContext['customer_user_id'],
            $extraContext['customer_number'],
            $extraContext['subuser_id'],
            $extraContext['relay_name'],
            $extraContext['relay_label'],
            $extraContext['relay_role'],
            $extraContext['role']
        );
        if ($extraContext !== []) {
            $context['action_context'] = $this->sanitizeRelayLogValue($extraContext);
        }

        $context = $this->compactRelayLogArray($context);
        $description = $this->buildRelayLogDescription($context, $success);
        if ($description !== '') {
            $context['description'] = $description;
        }

        return $this->compactRelayLogArray($context);
    }

    private function buildRelayLogMessage(array $context, bool $success): string
    {
        $description = trim((string)($context['description'] ?? ''));
        if ($description !== '') {
            return $description;
        }

        return $this->buildRelayLogDescription($context, $success);
    }

    private function buildRelayLogDescription(array $context, bool $success): string
    {
        $subject = $this->buildRelayLogSubject($context);
        $handler = strtolower(trim((string)($context['handler'] ?? 'local')));
        $channel = trim((string)($context['delivery_channel'] ?? ''));

        if (!$success) {
            return trim(sprintf('%s failed via %s', $subject, $handler));
        }

        return trim(sprintf(
            '%s handled by %s%s',
            $subject,
            $handler,
            $channel !== '' ? ' via ' . $channel : ''
        ));
    }

    private function buildRelayLogSubject(array $context): string
    {
        $action = strtoupper(trim((string)($context['action'] ?? 'RELAY')));
        $relayId = trim((string)($context['relay_id'] ?? 'unknown'));
        $relayName = trim((string)($context['relay_name'] ?? ''));
        $relayTarget = $relayName !== '' ? $relayName : $relayId;
        $relayRole = $this->normalizeRelayLogRole($context['relay_role'] ?? null);
        $state = $this->resolveRelayLogState($context);

        if (in_array($relayRole, ['ENTRY', 'EXIT'], true)) {
            $verb = $state === false ? 'Close' : 'Open';
            return trim(sprintf('%s %s %s', $verb, $relayRole, $relayTarget));
        }

        if (in_array($relayRole, ['MACHINE', 'PROGRAM_PICKER', 'CLEANER'], true) && $state !== null) {
            return trim(sprintf('%s %s %s', $relayRole, $state ? 'ON' : 'OFF', $relayTarget));
        }

        $targetState = $state !== null ? ($state ? ' ON' : ' OFF') : '';
        return trim(sprintf('Relay %s %s%s', $action, $relayTarget, $targetState));
    }

    private function resolveRelayLogState(array $context): ?bool
    {
        if (array_key_exists('target_on', $context) && $context['target_on'] !== null) {
            return (bool)$context['target_on'];
        }

        $signal = isset($context['signal']) && is_array($context['signal']) ? (array)$context['signal'] : [];
        $request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : [];
        if (array_key_exists('on', $request)) {
            return (bool)$request['on'];
        }

        $response = isset($context['response']) && is_array($context['response']) ? (array)$context['response'] : [];
        if (array_key_exists('on', $response)) {
            return (bool)$response['on'];
        }

        return null;
    }

    private function normalizeRelayLogRole(mixed $role): ?string
    {
        $normalized = strtoupper(trim((string)($role ?? '')));
        if ($normalized === '') {
            return null;
        }

        return match ($normalized) {
            'ENTRANCE', 'IN', 'INLET', 'ENTRY_GATE' => 'ENTRY',
            'OUT', 'OUTLET', 'EXIT_GATE' => 'EXIT',
            'MACHINE_PROGRAM_PICKER', 'PROGRAM_SELECTOR', 'PICKER' => 'PROGRAM_PICKER',
            'MACHINE_CLEANER' => 'CLEANER',
            default => $normalized,
        };
    }

    private function resolveRelayLogDisplayName(array $binding, array $actionContext, array $signal): ?string
    {
        $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
        $request = isset($signal['request']) && is_array($signal['request']) ? (array)$signal['request'] : [];
        $relayId = trim((string)($signal['relay_id'] ?? $binding['relay_id'] ?? $request['relayId'] ?? $request['id'] ?? ''));

        $directName = $this->firstRelayLogString([
            $actionContext['relay_name'] ?? null,
            $actionContext['relay_label'] ?? null,
            $metadata['relay_name'] ?? null,
            $metadata['relay_label'] ?? null,
            $metadata['name'] ?? null,
            $metadata['label'] ?? null,
            $request['relay_name'] ?? null,
            $request['relay_label'] ?? null,
        ]);
        if ($directName !== null) {
            return $directName;
        }

        $departmentName = $this->findDepartmentRelayName(
            isset($binding['department_id']) ? (int)$binding['department_id'] : 0,
            $relayId
        );
        if ($departmentName !== null) {
            return $departmentName;
        }

        foreach ($this->listShellyRelayOptionsForLocalIpLookup() as $option) {
            $optionRelayId = trim((string)($option['id'] ?? ''));
            if ($optionRelayId === '' || $optionRelayId !== $relayId) {
                continue;
            }

            $optionName = $this->firstRelayLogString([
                $option['name'] ?? null,
                $option['label'] ?? null,
                $option['device_name'] ?? null,
            ]);
            if ($optionName !== null) {
                return $optionName;
            }
        }

        return null;
    }

    private function firstRelayLogString(array $candidates): ?string
    {
        foreach ($candidates as $candidate) {
            $value = trim((string)($candidate ?? ''));
            if ($value !== '') {
                return $value;
            }
        }

        return null;
    }

    private function findDepartmentRelayName(int $departmentId, string $relayId): ?string
    {
        if ($departmentId <= 0 || trim($relayId) === '') {
            return null;
        }

        try {
            $rows = (new department_relays_o())->getFieldsWhere([
                'department' => $departmentId,
                'relay_id' => $relayId,
                'deleted_at' => null,
            ], ['id', 'name']);
        } catch (\Throwable) {
            return null;
        }

        foreach ($rows as $row) {
            $name = trim((string)($row['name'] ?? ''));
            if ($name !== '') {
                return $name;
            }
        }

        return null;
    }

    private function relayLogBindingPayload(array $binding): array
    {
        $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];

        return $this->compactRelayLogArray([
            'id' => isset($binding['id']) ? (int)$binding['id'] : null,
            'gateway_id' => isset($binding['gateway_id']) ? (int)$binding['gateway_id'] : null,
            'department_id' => isset($binding['department_id']) ? (int)$binding['department_id'] : null,
            'relay_id' => $binding['relay_id'] ?? null,
            'relay_name' => $this->firstRelayLogString([
                $metadata['relay_name'] ?? null,
                $metadata['relay_label'] ?? null,
                $metadata['name'] ?? null,
                $metadata['label'] ?? null,
            ]),
            'device_id' => $binding['device_id'] ?? null,
            'local_ip' => $binding['local_ip'] ?? null,
            'channel' => isset($binding['channel']) ? (int)$binding['channel'] : null,
        ]);
    }

    private function relayLogResponsePayload(array $result): array
    {
        if ($result === []) {
            return [];
        }

        return $this->compactRelayLogArray([
            'online' => array_key_exists('online', $result) ? (bool)$result['online'] : null,
            'on' => array_key_exists('on', $result) ? (bool)$result['on'] : null,
            'raw' => $this->sanitizeRelayLogValue($result['raw'] ?? $result),
        ]);
    }

    private function normalizeRelayTransportResponse(array|object|null $response): array
    {
        if ($response === null) {
            return [];
        }

        $normalized = is_array($response) ? (array)($response[0] ?? $response) : (array)$response;
        return [
            'online' => (bool)($normalized['online'] ?? true),
            'on' => (bool)($normalized['on']
                ?? $normalized['output']
                ?? $normalized['status']['switch:0']['output']
                ?? false),
            'raw' => (array)($normalized['raw'] ?? $normalized),
        ];
    }

    private function inferRelayIdFromTransportPayload(array $payload): ?string
    {
        $relayId = trim((string)($payload['id'] ?? $payload['relayId'] ?? $payload['relay_id'] ?? ''));
        if ($relayId !== '') {
            return $relayId;
        }

        $ids = (array)($payload['ids'] ?? []);
        foreach ($ids as $id) {
            $relayId = trim((string)$id);
            if ($relayId !== '') {
                return $relayId;
            }
        }

        return null;
    }

    private function inferRelayActionFromEndpoint(string $endpoint): string
    {
        return str_contains(strtolower($endpoint), '/get') ? 'STATUS' : 'SWITCH';
    }

    private function compactRelayLogArray(array $value): array
    {
        return array_filter(
            $value,
            static fn(mixed $entry): bool => $entry !== null && $entry !== '' && $entry !== []
        );
    }

    private function sanitizeRelayLogValue(mixed $value, int $depth = 0): mixed
    {
        if ($depth > 5) {
            return '[truncated]';
        }

        if (is_object($value)) {
            $value = (array)$value;
        }

        if (is_array($value)) {
            $result = [];
            $count = 0;
            foreach ($value as $key => $entry) {
                if ($count >= 80) {
                    $result['__truncated'] = true;
                    break;
                }
                $result[$key] = $this->sanitizeRelayLogValue($entry, $depth + 1);
                $count++;
            }
            return $result;
        }

        if (is_string($value) && strlen($value) > 2000) {
            return substr($value, 0, 2000) . '...';
        }

        if (is_scalar($value) || $value === null) {
            return $value;
        }

        return (string)$value;
    }

    /**
     * @throws Exception
     */
    private function dispatchGatewayCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array
    {
        $delivery = (array)($job->delivery_json->value() ?? []);
        $preferredChannel = (string)($delivery['preferred_channel'] ?? self::DELIVERY_CHANNEL_API);
        $requireFastPath = !empty($delivery['require_fast_path']);
        $waitTimeoutSeconds = self::COMMAND_WAIT_TIMEOUT_SECONDS;

        $effectiveStatus = self::resolveGatewayStatus(
            $gateway->status->value() === null ? null : (string)$gateway->status->value(),
            $gateway->last_heartbeat_at->value() === null ? null : (string)$gateway->last_heartbeat_at->value()
        );
        if (!$requireFastPath && !in_array($effectiveStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED], true)) {
            throw new Exception('Gateway agent is offline');
        }

        if ($preferredChannel === self::DELIVERY_CHANNEL_BROKER) {
            if ($this->buildBrokerInternalUrl() === null) {
                $this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, 'Edge broker is not configured', 'broker_not_configured');
                if ($requireFastPath) {
                    $this->finalizeCommandJob($job, false, [], 'Edge broker is not configured', $gateway);
                    throw new Exception('Edge broker is not configured');
                }
            } else {
                try {
                    $this->markCommandJobDispatching($job, self::DELIVERY_CHANNEL_BROKER);
                    $payload = $this->dispatchBrokerCommand($gateway, $job);
                    $this->finalizeCommandJob($job, true, $payload, null, $gateway);
                    return $payload;
                } catch (Exception $exception) {
                    $this->markCommandDeliveryFailure($job, self::DELIVERY_CHANNEL_BROKER, $exception->getMessage(), 'broker_dispatch_failed');
                    if ($requireFastPath) {
                        $this->finalizeCommandJob(
                            $job,
                            false,
                            [],
                            $exception->getMessage(),
                            $gateway,
                            $this->isCommandTimeoutError($exception->getMessage()) ? 'TIMED_OUT' : 'FAILED'
                        );
                        throw $exception;
                    }

                    if ($this->commandDisallowsAutomaticRetry($job)) {
                        $this->finalizeCommandJob(
                            $job,
                            false,
                            [],
                            $exception->getMessage(),
                            $gateway,
                            $this->isCommandTimeoutError($exception->getMessage()) ? 'TIMED_OUT' : 'FAILED'
                        );
                        throw $exception;
                    }

                    $this->requeueCommandForApiPolling($job, $exception->getMessage());
                    $waitTimeoutSeconds = max(
                        self::COMMAND_WAIT_TIMEOUT_SECONDS,
                        self::COMMAND_POLL_TIMEOUT_SECONDS + 5
                    );
                }
            }
        }

        if ($requireFastPath) {
            $this->finalizeCommandJob($job, false, [], 'Edge broker fast path is unavailable', $gateway);
            throw new Exception('Edge broker fast path is unavailable');
        }

        return $this->waitForCommandResult((int)$job->id, $waitTimeoutSeconds);
    }

    private function commandDisallowsAutomaticRetry(edge_gateway_command_jobs_o $job): bool
    {
        $delivery = (array)($job->delivery_json->value() ?? []);
        return filter_var($delivery['no_auto_retry'] ?? false, FILTER_VALIDATE_BOOLEAN);
    }

    private function requeueCommandForApiPolling(edge_gateway_command_jobs_o $job, string $errorMessage): void
    {
        $delivery = $this->buildDeliveryMetadata(
            array_merge(
                (array)($job->delivery_json->value() ?? []),
                [
                    'preferred_channel' => self::DELIVERY_CHANNEL_API,
                    'delivery_channel' => null,
                    'fallback_reason' => 'broker_dispatch_failed',
                    'last_dispatch_error' => $errorMessage,
                ]
            ),
            self::COMMAND_EXPIRES_AFTER_SECONDS
        );

        $job->status->set('PENDING');
        $job->response_json->set([]);
        $job->completed_at->set(null);
        $job->error_message->set(null);
        $job->delivery_json->set($delivery);
    }

    private function isCommandTimeoutError(string $errorMessage): bool
    {
        return str_contains(strtolower(trim($errorMessage)), 'timed out');
    }

    private function markCommandJobDispatching(
        edge_gateway_command_jobs_o $job,
        string $channel,
        ?string $fallbackReason = null
    ): void {
        $delivery = $this->buildDeliveryMetadata(
            array_merge(
                (array)($job->delivery_json->value() ?? []),
                [
                    'delivery_channel' => $channel,
                    'attempt_count' => (int)((array)($job->delivery_json->value() ?? [])['attempt_count'] ?? 0) + 1,
                    'fallback_reason' => $fallbackReason,
                    'last_dispatch_error' => null,
                ]
            ),
            self::COMMAND_EXPIRES_AFTER_SECONDS
        );

        $job->status->set('DISPATCHING');
        $job->response_json->set([]);
        $job->completed_at->set(null);
        $job->error_message->set(null);
        $job->delivery_json->set($delivery);
    }

    private function markCommandDeliveryFailure(
        edge_gateway_command_jobs_o $job,
        string $channel,
        string $errorMessage,
        ?string $fallbackReason = null
    ): void {
        $delivery = $this->buildDeliveryMetadata(
            array_merge(
                (array)($job->delivery_json->value() ?? []),
                [
                    'delivery_channel' => $channel,
                    'fallback_reason' => $fallbackReason,
                    'last_dispatch_error' => $errorMessage,
                ]
            ),
            self::COMMAND_EXPIRES_AFTER_SECONDS
        );
        $job->delivery_json->set($delivery);
    }

    /**
     * @throws Exception
     */
    private function dispatchBrokerCommand(edge_gateways_o $gateway, edge_gateway_command_jobs_o $job): array
    {
        $brokerUrl = $this->buildBrokerInternalUrl();
        if ($brokerUrl === null) {
            throw new Exception('Edge broker is not configured');
        }

        $result = $this->httpJsonRequest(
            $brokerUrl . '/api/gateways/' . (int)$gateway->id . '/commands',
            [
                'commandType' => (string)$job->command_type->value(),
                'payload' => $this->buildCommandExecutionPayload($job, $gateway),
            ],
            [
                'x-edge-broker-secret: ' . $this->configuredBrokerSharedSecret(),
            ],
            self::BROKER_HTTP_TIMEOUT_SECONDS
        );

        if (!is_array($result) || empty($result['ok'])) {
            throw new Exception(trim((string)($result['error'] ?? 'Edge broker dispatch failed')) ?: 'Edge broker dispatch failed');
        }

        return isset($result['payload']) && is_array($result['payload']) ? (array)$result['payload'] : [];
    }

    /**
     * @throws Exception
     */
    private function dispatchRelayThroughCloud(
        int $departmentId,
        string $logicalRelayId,
        ?bool $on,
        array $binding,
        array $resolution,
        ?int $toggleAfterSeconds = null,
        array $actionContext = []
    ): array {
        $transport = new cloud_shelly_transport(null, false);
        $setPayload = ['id' => $logicalRelayId, 'on' => $on];
        if ($on !== null && $toggleAfterSeconds !== null) {
            $setPayload['toggle_after'] = $toggleAfterSeconds;
        }
        $endpoint = $on === null ? '/v2/devices/api/get' : '/v2/devices/api/set/switch';
        $request = $on === null ? ['ids' => [$logicalRelayId]] : $setPayload;
        $actionContext = $this->normalizeRelayActionContext($actionContext);
        $dispatchLog = [
            'action' => $on === null ? 'STATUS' : 'SWITCH',
            'handler' => 'cloud',
            'relay_id' => $logicalRelayId,
            'target_on' => $on,
            'toggle_after_seconds' => $toggleAfterSeconds,
            'signal' => [
                'endpoint' => $endpoint,
                'request' => $request,
            ],
            'action_context' => $actionContext,
        ];

        try {
            $response = $transport->sendPostRequest($endpoint, $request, $departmentId);
        } catch (\Throwable $exception) {
            $this->appendRelayDispatchLog(
                $binding,
                array_merge($resolution, [
                    'execution_path' => 'cloud',
                    'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD,
                ]),
                false,
                $dispatchLog,
                [],
                $exception
            );
            throw $exception;
        }

        $normalized = is_array($response) ? (array)($response[0] ?? []) : (array)$response;
        $result = [
            'online' => (bool)($normalized['online'] ?? true),
            'on' => (bool)($normalized['on']
                ?? $normalized['output']
                ?? $normalized['status']['switch:0']['output']
                ?? $on
                ?? false),
            'raw' => (array)($normalized['raw'] ?? $normalized),
        ];

        return $this->finalizeRelayDispatch(
            $binding,
            array_merge($resolution, [
                'execution_path' => 'cloud',
                'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD,
            ]),
            $result,
            $dispatchLog
        );
    }

    /**
     * @throws Exception
     */
    private function handleRelayDispatchFailure(
        int $departmentId,
        string $logicalRelayId,
        array $binding,
        array $resolution,
        ?bool $on,
        Exception $exception,
        ?int $toggleAfterSeconds = null,
        array $actionContext = []
    ): array {
        $recommendedAction = $this->mapRelayFailureToRecommendedAction($exception->getMessage());
        $this->recordRelayBindingResolution(
            $binding,
            array_merge($resolution, [
                'execution_path' => 'local',
                'delivery_channel' => (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
                'reason' => 'local_dispatch_failed',
                'recommended_action' => $recommendedAction,
                'recovery_actions' => [$recommendedAction],
            ]),
            false,
            $exception->getMessage()
        );

        if ((string)($resolution['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL) !== self::RELAY_FALLBACK_PREFER_LOCAL) {
            throw $exception;
        }

        try {
            return $this->dispatchRelayThroughCloud(
                $departmentId,
                $logicalRelayId,
                $on,
                $binding,
                array_merge($resolution, [
                    'execution_path' => 'cloud',
                    'reason' => 'local_dispatch_failed',
                    'fallback_reason' => $exception->getMessage(),
                    'recommended_action' => $recommendedAction,
                    'recovery_actions' => [$recommendedAction, 'force_cloud'],
                ]),
                $toggleAfterSeconds,
                $actionContext
            );
        } catch (Exception $cloudException) {
            $this->recordRelayBindingResolution(
                $binding,
                array_merge($resolution, [
                    'execution_path' => 'cloud',
                    'delivery_channel' => self::DELIVERY_CHANNEL_CLOUD,
                    'reason' => 'cloud_fallback_failed',
                    'recommended_action' => $recommendedAction,
                ]),
                false,
                $cloudException->getMessage()
            );
            throw $cloudException;
        }
    }

    private function finalizeRelayDispatch(array $binding, array $resolution, array $result, array $dispatchLog = []): array
    {
        $executionPath = (string)($resolution['execution_path'] ?? 'local');
        $deliveryChannel = $executionPath === 'cloud'
            ? self::DELIVERY_CHANNEL_CLOUD
            : (string)($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API);

        $resolutionPayload = array_merge($resolution, [
            'delivery_channel' => $deliveryChannel,
            'execution_path' => $executionPath,
        ]);
        $this->recordRelayBindingResolution($binding, $resolutionPayload, true, null);
        if ($dispatchLog !== []) {
            $this->appendRelayDispatchLog($binding, $resolutionPayload, true, $dispatchLog, $result, null);
        }

        return array_merge($result, [
            'binding' => $this->reloadRelayBinding((int)$binding['id']),
            'execution' => [
                'path' => $executionPath,
                'channel' => $deliveryChannel,
                'reason' => $resolutionPayload['reason'] ?? null,
                'fallback_mode' => $resolutionPayload['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL,
                'recommended_action' => $resolutionPayload['recommended_action'] ?? null,
            ],
            'raw' => (array)($result['raw'] ?? []),
        ]);
    }

    private function reloadRelayBinding(int $bindingId): array
    {
        $binding = (new edge_gateway_relay_bindings_o())->select($bindingId);
        return $binding->exists() ? $binding->asArray() : [];
    }

    private function recordRelayBindingResolution(
        array $binding,
        array $resolution,
        bool $success,
        ?string $errorMessage
    ): void {
        $bindingObject = (new edge_gateway_relay_bindings_o())->select((int)$binding['id']);
        if (!$bindingObject->exists()) {
            return;
        }

        $metadata = $this->normalizeRelayBindingMetadata((array)($bindingObject->metadata_json->value() ?? []), $binding);
        $metadata['last_resolution'] = [
            'at' => $this->now(),
            'execution_path' => $resolution['execution_path'] ?? 'local',
            'delivery_channel' => $resolution['delivery_channel'] ?? ($resolution['preferred_channel'] ?? self::DELIVERY_CHANNEL_API),
            'reason' => $resolution['reason'] ?? null,
            'fallback_mode' => $metadata['fallback_mode'],
            'recommended_action' => $resolution['recommended_action'] ?? null,
            'recovery_actions' => array_values(array_filter((array)($resolution['recovery_actions'] ?? []))),
            'gateway_status' => $resolution['gateway_status'] ?? null,
            'device_online' => $resolution['device_online'] ?? null,
            'device_freshness_seconds' => $resolution['device_freshness_seconds'] ?? null,
            'device_freshness_state' => $resolution['device_freshness_state'] ?? null,
        ];

        if ($success) {
            $metadata['last_success_at'] = $this->now();
            $metadata['last_error'] = null;
        } else {
            $metadata['last_error'] = $errorMessage;
        }

        $bindingObject->metadata_json->set($metadata);
    }

    private function mapRelayFailureToRecommendedAction(string $errorMessage): string
    {
        $normalized = strtolower(trim($errorMessage));
        if ($normalized === '') {
            return 'retry_local_command';
        }
        if (str_contains($normalized, 'credential') || str_contains($normalized, 'token')) {
            return 'rotate_credentials';
        }
        if (str_contains($normalized, 'discovery') || str_contains($normalized, 'device')) {
            return 'retry_discovery';
        }
        if (str_contains($normalized, 'update')) {
            return 'retry_update';
        }
        if (str_contains($normalized, 'offline') || str_contains($normalized, 'timeout') || str_contains($normalized, 'broker')) {
            return 'restart_agent';
        }

        return 'retry_local_command';
    }

    /**
     * @throws Exception
     */
    private function httpJsonRequest(string $url, array $payload, array $headers = [], int $timeoutSeconds = 5): array
    {
        $defaultHeaders = [
            'Content-Type: application/json',
            'Accept: application/json',
        ];

        $context = stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => implode("\r\n", array_filter(array_merge($defaultHeaders, $headers))),
                'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
                'timeout' => max(1, $timeoutSeconds),
                'ignore_errors' => true,
            ],
        ]);

        $response = @file_get_contents($url, false, $context);
        if ($response === false) {
            throw new Exception('Unable to reach edge broker');
        }

        $decoded = json_decode($response, true);
        if (!is_array($decoded)) {
            throw new Exception('Edge broker returned an invalid response');
        }

        $statusLine = is_array($http_response_header ?? null) ? (string)($http_response_header[0] ?? '') : '';
        if ($statusLine !== '' && preg_match('/\s(\d{3})\s/', $statusLine, $matches) === 1) {
            $statusCode = (int)$matches[1];
            if ($statusCode >= 400) {
                throw new Exception(trim((string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')) ?: 'Edge broker request failed');
            }
        }

        return $decoded;
    }

    /**
     * @param array<int,int> $gatewayIds
     * @return array<string,int>
     */
    private function aggregateInventoryUsage(array $gatewayIds): array
    {
        if ($gatewayIds === []) {
            return self::emptyInventoryUsage();
        }

        $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?'));
        $statement = db::getPDO()->prepare(
            "SELECT
                COUNT(*) AS total,
                SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total,
                SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total
             FROM edge_gateway_device_inventory
             WHERE deleted_at IS NULL
               AND gateway_id IN ($placeholders)"
        );
        $statement->execute($gatewayIds);
        $row = $statement->fetch();

        return [
            'total' => (int)($row['total'] ?? 0),
            'online' => (int)($row['online_total'] ?? 0),
            'offline' => (int)($row['offline_total'] ?? 0),
        ];
    }

    /**
     * @param array<int,int> $gatewayIds
     * @return array<string,int>
     */
    private function aggregateBindingUsage(array $gatewayIds): array
    {
        if ($gatewayIds === []) {
            return self::emptyBindingUsage();
        }

        $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?'));
        $statement = db::getPDO()->prepare(
            "SELECT
                COUNT(*) AS total,
                SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides,
                SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total,
                SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total
             FROM edge_gateway_relay_bindings
             WHERE deleted_at IS NULL
               AND gateway_id IN ($placeholders)"
        );
        $statement->execute(array_merge([
            self::RELAY_FALLBACK_PREFER_LOCAL,
            self::RELAY_FALLBACK_CLOUD_ONLY,
            self::RELAY_FALLBACK_LOCAL_ONLY,
        ], $gatewayIds));
        $row = $statement->fetch();

        return [
            'total' => (int)($row['total'] ?? 0),
            'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0),
            'cloud_only' => (int)($row['cloud_only_total'] ?? 0),
            'local_only' => (int)($row['local_only_total'] ?? 0),
        ];
    }

    /**
     * @param array<int,int> $gatewayIds
     * @return array<int,array<string,int>>
     */
    private function aggregateInventoryUsageByGateway(array $gatewayIds): array
    {
        if ($gatewayIds === []) {
            return [];
        }

        $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?'));
        $statement = db::getPDO()->prepare(
            "SELECT
                gateway_id,
                COUNT(*) AS total,
                SUM(CASE WHEN online = 1 THEN 1 ELSE 0 END) AS online_total,
                SUM(CASE WHEN online = 0 THEN 1 ELSE 0 END) AS offline_total
             FROM edge_gateway_device_inventory
             WHERE deleted_at IS NULL
               AND gateway_id IN ($placeholders)
             GROUP BY gateway_id"
        );
        $statement->execute($gatewayIds);

        $rows = [];
        while (($row = $statement->fetch()) !== false) {
            if (!is_array($row) || !isset($row['gateway_id'])) {
                continue;
            }

            $rows[(int)$row['gateway_id']] = [
                'total' => (int)($row['total'] ?? 0),
                'online' => (int)($row['online_total'] ?? 0),
                'offline' => (int)($row['offline_total'] ?? 0),
            ];
        }

        return $rows;
    }

    /**
     * @param array<int,int> $gatewayIds
     * @return array<int,array<string,int>>
     */
    private function aggregateBindingUsageByGateway(array $gatewayIds): array
    {
        if ($gatewayIds === []) {
            return [];
        }

        $placeholders = implode(', ', array_fill(0, count($gatewayIds), '?'));
        $statement = db::getPDO()->prepare(
            "SELECT
                gateway_id,
                COUNT(*) AS total,
                SUM(CASE WHEN fallback_mode <> ? THEN 1 ELSE 0 END) AS fallback_overrides,
                SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS cloud_only_total,
                SUM(CASE WHEN fallback_mode = ? THEN 1 ELSE 0 END) AS local_only_total
             FROM edge_gateway_relay_bindings
             WHERE deleted_at IS NULL
               AND gateway_id IN ($placeholders)
             GROUP BY gateway_id"
        );
        $statement->execute(array_merge([
            self::RELAY_FALLBACK_PREFER_LOCAL,
            self::RELAY_FALLBACK_CLOUD_ONLY,
            self::RELAY_FALLBACK_LOCAL_ONLY,
        ], $gatewayIds));

        $rows = [];
        while (($row = $statement->fetch()) !== false) {
            if (!is_array($row) || !isset($row['gateway_id'])) {
                continue;
            }

            $rows[(int)$row['gateway_id']] = [
                'total' => (int)($row['total'] ?? 0),
                'fallback_overrides' => (int)($row['fallback_overrides'] ?? 0),
                'cloud_only' => (int)($row['cloud_only_total'] ?? 0),
                'local_only' => (int)($row['local_only_total'] ?? 0),
            ];
        }

        return $rows;
    }

    /**
     * @param array<int,array<string,mixed>> $gateways
     * @param array<int,array<string,int>> $inventoryUsageByGateway
     * @param array<int,array<string,int>> $bindingUsageByGateway
     * @return array<int,array<string,mixed>>
     */
    private static function attachGatewayCollectionSummaries(
        array $gateways,
        array $inventoryUsageByGateway = [],
        array $bindingUsageByGateway = []
    ): array {
        foreach ($gateways as $index => $gateway) {
            if (!is_array($gateway)) {
                continue;
            }

            $gatewayId = (int)($gateway['id'] ?? 0);
            $gateways[$index] = self::decorateGatewayUsageSummaries(
                $gateway,
                $inventoryUsageByGateway[$gatewayId] ?? null,
                $bindingUsageByGateway[$gatewayId] ?? null
            );
        }

        return $gateways;
    }

    /**
     * @param array<int,array<string,mixed>> $gateways
     * @return array<string,int>
     */
    private static function aggregateInventoryUsageFromGatewayRows(array $gateways): array
    {
        $totals = self::emptyInventoryUsage();

        foreach ($gateways as $gateway) {
            if (!is_array($gateway)) {
                continue;
            }

            $summary = self::resolveInventoryUsageForGateway($gateway);
            $totals['total'] += (int)($summary['total'] ?? 0);
            $totals['online'] += (int)($summary['online'] ?? 0);
            $totals['offline'] += (int)($summary['offline'] ?? 0);
        }

        return $totals;
    }

    /**
     * @param array<int,array<string,mixed>> $gateways
     * @return array<string,int>
     */
    private static function aggregateBindingUsageFromGatewayRows(array $gateways): array
    {
        $totals = self::emptyBindingUsage();

        foreach ($gateways as $gateway) {
            if (!is_array($gateway)) {
                continue;
            }

            $summary = self::resolveBindingUsageForGateway($gateway);
            $totals['total'] += (int)($summary['total'] ?? 0);
            $totals['fallback_overrides'] += (int)($summary['fallback_overrides'] ?? 0);
            $totals['cloud_only'] += (int)($summary['cloud_only'] ?? 0);
            $totals['local_only'] += (int)($summary['local_only'] ?? 0);
        }

        return $totals;
    }

    /**
     * @param array<string,mixed> $gateway
     * @param array<string,int>|null $inventoryUsage
     * @param array<string,int>|null $bindingUsage
     * @return array<string,mixed>
     */
    private static function decorateGatewayUsageSummaries(
        array $gateway,
        ?array $inventoryUsage = null,
        ?array $bindingUsage = null
    ): array {
        $inventory = self::resolveInventoryUsageForGateway($gateway, $inventoryUsage);
        $bindings = self::resolveBindingUsageForGateway($gateway, $bindingUsage);

        $gateway['inventory_summary'] = $inventory;
        $gateway['binding_summary'] = [
            'total' => (int)($bindings['total'] ?? 0),
            'fallback_overrides' => (int)($bindings['fallback_overrides'] ?? 0),
        ];

        $fallbackSummary = isset($gateway['fallback_summary']) && is_array($gateway['fallback_summary'])
            ? (array)$gateway['fallback_summary']
            : [];
        $gateway['fallback_summary'] = array_merge($fallbackSummary, [
            'cloud_only_relays' => (int)($fallbackSummary['cloud_only_relays'] ?? $bindings['cloud_only'] ?? 0),
            'local_only_relays' => (int)($fallbackSummary['local_only_relays'] ?? $bindings['local_only'] ?? 0),
        ]);

        return $gateway;
    }

    /**
     * @param array<string,mixed> $gateway
     * @param array<string,int>|null $summary
     * @return array<string,int>
     */
    private static function resolveInventoryUsageForGateway(array $gateway, ?array $summary = null): array
    {
        if ($summary !== null) {
            return array_merge(self::emptyInventoryUsage(), $summary);
        }

        if (isset($gateway['inventory_summary']) && is_array($gateway['inventory_summary'])) {
            return array_merge(self::emptyInventoryUsage(), array_map('intval', $gateway['inventory_summary']));
        }

        $inventory = isset($gateway['inventory']) && is_array($gateway['inventory']) ? $gateway['inventory'] : [];
        $online = 0;
        $offline = 0;
        foreach ($inventory as $device) {
            if (!is_array($device)) {
                continue;
            }

            if (($device['online'] ?? true) === false) {
                $offline += 1;
                continue;
            }

            $online += 1;
        }

        return [
            'total' => count($inventory),
            'online' => $online,
            'offline' => $offline,
        ];
    }

    /**
     * @param array<string,mixed> $gateway
     * @param array<string,int>|null $summary
     * @return array<string,int>
     */
    private static function resolveBindingUsageForGateway(array $gateway, ?array $summary = null): array
    {
        if ($summary !== null) {
            return array_merge(self::emptyBindingUsage(), $summary);
        }

        if (isset($gateway['binding_summary']) && is_array($gateway['binding_summary'])) {
            $fallbackSummary = isset($gateway['fallback_summary']) && is_array($gateway['fallback_summary'])
                ? (array)$gateway['fallback_summary']
                : [];

            return array_merge(self::emptyBindingUsage(), [
                'total' => (int)($gateway['binding_summary']['total'] ?? 0),
                'fallback_overrides' => (int)($gateway['binding_summary']['fallback_overrides'] ?? 0),
                'cloud_only' => (int)($fallbackSummary['cloud_only_relays'] ?? 0),
                'local_only' => (int)($fallbackSummary['local_only_relays'] ?? 0),
            ]);
        }

        $bindings = isset($gateway['bindings']) && is_array($gateway['bindings']) ? $gateway['bindings'] : [];
        $fallbackOverrides = 0;
        $cloudOnly = 0;
        $localOnly = 0;
        foreach ($bindings as $binding) {
            if (!is_array($binding)) {
                continue;
            }

            $metadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
            $fallbackMode = isset($metadata['fallback_mode'])
                ? (string)$metadata['fallback_mode']
                : (string)($binding['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL);
            $fallbackMode = self::normalizeFallbackMode($fallbackMode);

            if ($fallbackMode !== self::RELAY_FALLBACK_PREFER_LOCAL) {
                $fallbackOverrides += 1;
            }
            if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) {
                $cloudOnly += 1;
            }
            if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) {
                $localOnly += 1;
            }
        }

        return [
            'total' => count($bindings),
            'fallback_overrides' => $fallbackOverrides,
            'cloud_only' => $cloudOnly,
            'local_only' => $localOnly,
        ];
    }

    /**
     * @return array<string,int>
     */
    private static function emptyInventoryUsage(): array
    {
        return [
            'total' => 0,
            'online' => 0,
            'offline' => 0,
        ];
    }

    /**
     * @return array<string,int>
     */
    private static function emptyBindingUsage(): array
    {
        return [
            'total' => 0,
            'fallback_overrides' => 0,
            'cloud_only' => 0,
            'local_only' => 0,
        ];
    }

    /**
     * @param array<int,float|int> $values
     */
    private static function appendNumericMetric(array &$values, mixed $value): void
    {
        if (!is_int($value) && !is_float($value) && !(is_string($value) && is_numeric($value))) {
            return;
        }

        $values[] = (float)$value;
    }

    /**
     * @param array<int,float|int> $values
     */
    private static function averageMetric(array $values): ?int
    {
        if ($values === []) {
            return null;
        }

        return (int)round(array_sum($values) / count($values));
    }

    public static function deriveGatewayRuntimeState(array $gateway, ?int $now = null): array
    {
        $effectiveStatus = self::resolveGatewayStatus(
            isset($gateway['status']) ? (string)$gateway['status'] : null,
            isset($gateway['last_heartbeat_at']) && $gateway['last_heartbeat_at'] !== null
                ? (string)$gateway['last_heartbeat_at']
                : null,
            $now
        );

        $gateway['status'] = $effectiveStatus;
        $gateway['discovery_status'] = self::resolveDiscoveryStatus(
            isset($gateway['discovery_status']) ? (string)$gateway['discovery_status'] : null,
            $effectiveStatus
        );
        $gateway['metadata'] = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $gateway['operational_snapshot'] = isset($gateway['operational_snapshot']) && is_array($gateway['operational_snapshot'])
            ? (array)$gateway['operational_snapshot']
            : [];

        $channelStatus = self::deriveChannelStatus($gateway, $effectiveStatus, $now);
        $relayHealth = self::buildRelayHealth($gateway, $effectiveStatus, $now);
        $fallbackSummary = self::buildFallbackSummary($relayHealth);
        $gateway['outbox_status'] = self::buildOutboxStatusSummary($gateway, $now);
        $lastSyncAt = self::resolveLastSyncAt($gateway);

        $gateway['channel_status'] = $channelStatus;
        $gateway['relay_health'] = $relayHealth;
        $gateway['fallback_summary'] = $fallbackSummary;
        $gateway['transport_health'] = self::deriveTransportHealth(
            $gateway,
            $effectiveStatus,
            $channelStatus,
            $fallbackSummary,
            $lastSyncAt
        );
        $gateway['last_successful_command_at'] = $gateway['operational_snapshot']['last_successful_command_at']
            ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null);
        $gateway['last_successful_discovery_at'] = $gateway['operational_snapshot']['last_successful_discovery_at']
            ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), 'DISCOVER_SHELLY');
        $gateway['last_successful_operation_at'] = $gateway['operational_snapshot']['last_successful_operation_at'] ?? null;
        $gateway['backlog_depth'] = [
            'commands' => (int)($gateway['operational_snapshot']['command_backlog'] ?? 0),
            'operations' => (int)($gateway['operational_snapshot']['operation_backlog'] ?? 0),
        ];
        $gateway['version_drift'] = self::buildVersionDriftSummary($gateway);
        $gateway['credential_freshness'] = self::buildCredentialFreshnessSummary($gateway, $now);
        $gateway['container_health'] = self::buildContainerHealthSummary($gateway, $effectiveStatus);
        $gateway['last_sync_at'] = $lastSyncAt;
        $gateway['update_window'] = self::buildUpdateWindowSummary($gateway);
        $gateway['staged_version'] = self::buildStagedVersionSummary($gateway);
        $gateway['rollback_status'] = self::buildRollbackStatusSummary($gateway);
        $gateway['diagnostics'] = self::buildGatewayDiagnostics($gateway, $effectiveStatus, $now);
        $gateway['error_state'] = self::primaryGatewayErrorState($gateway['diagnostics'], $gateway);

        return $gateway;
    }

    private static function deriveChannelStatus(array $gateway, string $effectiveStatus, ?int $now = null): array
    {
        $metadata = (array)($gateway['metadata'] ?? []);
        $operational = (array)($gateway['operational_snapshot'] ?? []);
        $brokerPresence = isset($metadata['broker_presence']) && is_array($metadata['broker_presence'])
            ? (array)$metadata['broker_presence']
            : [];
        $brokerLastSeenAt = isset($brokerPresence['last_seen_at']) ? (string)$brokerPresence['last_seen_at'] : null;
        $brokerAgeSeconds = self::heartbeatAgeSeconds($brokerLastSeenAt, $now);
        $brokerConnected = self::isBrokerPresenceConnected($brokerPresence, $now);
        $brokerHealthy = $brokerConnected
            && $brokerAgeSeconds !== null
            && $brokerAgeSeconds < self::BROKER_CONNECTIVITY_DEGRADED_AFTER_SECONDS;
        $commandPreferred = $brokerConnected ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API;

        return [
            'command' => [
                'preferred' => $commandPreferred,
                'active' => $brokerHealthy ? self::DELIVERY_CHANNEL_BROKER : self::DELIVERY_CHANNEL_API,
                'state' => $effectiveStatus === self::STATUS_OFFLINE
                    ? self::STATUS_OFFLINE
                    : ($brokerConnected ? ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED) : self::STATUS_DEGRADED),
                'backlog_depth' => (int)($operational['command_backlog'] ?? 0),
                'last_success_at' => $operational['last_successful_command_at']
                    ?? self::findLatestCompletionTimestamp((array)($gateway['recent_commands'] ?? []), null),
            ],
            'broker' => [
                'connected' => $brokerConnected,
                'state' => !$brokerConnected
                    ? self::STATUS_OFFLINE
                    : ($brokerHealthy ? self::STATUS_ONLINE : self::STATUS_DEGRADED),
                'last_seen_at' => $brokerLastSeenAt,
                'disconnect_reason' => isset($brokerPresence['disconnect_reason']) ? (string)$brokerPresence['disconnect_reason'] : null,
                'last_error' => isset($brokerPresence['last_error']) ? (string)$brokerPresence['last_error'] : null,
            ],
        ];
    }

    private static function isBrokerPresenceConnected(array $presence, ?int $now = null): bool
    {
        if (empty($presence['connected'])) {
            return false;
        }

        $lastSeenAt = isset($presence['last_seen_at']) ? (string)$presence['last_seen_at'] : null;
        $ageSeconds = self::heartbeatAgeSeconds($lastSeenAt, $now);

        return $ageSeconds !== null && $ageSeconds < self::BROKER_PRESENCE_TTL_SECONDS;
    }

    private static function buildRelayHealth(array $gateway, string $effectiveStatus, ?int $now = null): array
    {
        $bindings = is_array($gateway['bindings'] ?? null) ? (array)$gateway['bindings'] : [];
        $inventory = is_array($gateway['inventory'] ?? null) ? (array)$gateway['inventory'] : [];
        $inventoryByDeviceId = [];
        foreach ($inventory as $device) {
            if (!is_array($device)) {
                continue;
            }
            $deviceId = trim((string)($device['device_id'] ?? ''));
            if ($deviceId !== '') {
                $inventoryByDeviceId[$deviceId] = $device;
            }
        }

        $departmentTransportMode = (string)($gateway['department_transport_mode'] ?? self::TRANSPORT_MODE_CLOUD);
        $relayHealth = [];
        foreach ($bindings as $binding) {
            if (!is_array($binding)) {
                continue;
            }

            $bindingMetadata = isset($binding['metadata']) && is_array($binding['metadata']) ? (array)$binding['metadata'] : [];
            $fallbackMode = self::normalizeFallbackMode((string)($binding['fallback_mode'] ?? $bindingMetadata['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL));
            $device = isset($inventoryByDeviceId[(string)($binding['device_id'] ?? '')])
                ? (array)$inventoryByDeviceId[(string)$binding['device_id']]
                : null;
            $deviceLastSeenAt = is_array($device) && isset($device['last_seen_at']) ? (string)$device['last_seen_at'] : null;
            $deviceFreshnessSeconds = self::heartbeatAgeSeconds($deviceLastSeenAt, $now);
            $deviceOnline = is_array($device) && array_key_exists('online', $device) ? (bool)$device['online'] : null;
            $deviceFresh = $device !== null
                && $deviceOnline !== false
                && $deviceFreshnessSeconds !== null
                && $deviceFreshnessSeconds < self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS;

            $executionPath = 'local';
            $reason = null;
            if ($departmentTransportMode === self::TRANSPORT_MODE_CLOUD) {
                $executionPath = 'cloud';
                $reason = 'department_cutover';
            } elseif ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) {
                $executionPath = 'cloud';
                $reason = 'binding_cloud_only';
            } elseif ($effectiveStatus === self::STATUS_OFFLINE) {
                $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud';
                $reason = 'gateway_offline';
            } elseif ($device === null) {
                $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud';
                $reason = 'device_missing';
            } elseif (!$deviceFresh) {
                $executionPath = $fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY ? 'local' : 'cloud';
                $reason = $deviceOnline === false ? 'device_offline' : 'device_stale';
            }

            $recommendedAction = match ($reason) {
                'department_cutover' => 'review_department_cutover',
                'binding_cloud_only' => 'review_binding_override',
                'gateway_offline' => 'restart_agent',
                'device_missing', 'device_stale', 'device_offline' => 'retry_discovery',
                default => null,
            };

            $relayHealth[] = [
                'binding_id' => isset($binding['id']) ? (int)$binding['id'] : null,
                'relay_id' => isset($binding['relay_id']) ? (string)$binding['relay_id'] : null,
                'device_id' => isset($binding['device_id']) ? (string)$binding['device_id'] : null,
                'fallback_mode' => $fallbackMode,
                'execution_path' => $executionPath,
                'reason' => $reason,
                'recommended_action' => $recommendedAction,
                'recovery_actions' => array_values(array_filter([$recommendedAction])),
                'device_online' => $deviceOnline,
                'device_freshness_seconds' => $deviceFreshnessSeconds,
                'device_freshness_state' => self::resolveDeviceFreshnessState($device, $deviceFreshnessSeconds),
                'gateway_status' => $effectiveStatus,
                'last_resolution' => isset($binding['last_resolution']) && is_array($binding['last_resolution'])
                    ? (array)$binding['last_resolution']
                    : (isset($bindingMetadata['last_resolution']) && is_array($bindingMetadata['last_resolution'])
                        ? (array)$bindingMetadata['last_resolution']
                        : null),
                'last_success_at' => $binding['last_success_at'] ?? $bindingMetadata['last_success_at'] ?? null,
                'last_error' => $binding['last_error'] ?? $bindingMetadata['last_error'] ?? null,
            ];
        }

        return $relayHealth;
    }

    private static function buildFallbackSummary(array $relayHealth): array
    {
        $summary = [
            'local_relays' => 0,
            'cloud_relays' => 0,
            'local_only_relays' => 0,
            'cloud_only_relays' => 0,
            'affected_relays' => [],
            'recommended_action' => null,
        ];

        foreach ($relayHealth as $relay) {
            $executionPath = (string)($relay['execution_path'] ?? 'local');
            if ($executionPath === 'cloud') {
                $summary['cloud_relays'] += 1;
                if (!empty($relay['relay_id'])) {
                    $summary['affected_relays'][] = (string)$relay['relay_id'];
                }
                if ($summary['recommended_action'] === null && !empty($relay['recommended_action'])) {
                    $summary['recommended_action'] = (string)$relay['recommended_action'];
                }
            } else {
                $summary['local_relays'] += 1;
            }

            $fallbackMode = (string)($relay['fallback_mode'] ?? self::RELAY_FALLBACK_PREFER_LOCAL);
            if ($fallbackMode === self::RELAY_FALLBACK_LOCAL_ONLY) {
                $summary['local_only_relays'] += 1;
            }
            if ($fallbackMode === self::RELAY_FALLBACK_CLOUD_ONLY) {
                $summary['cloud_only_relays'] += 1;
            }
        }

        return $summary;
    }

    private static function deriveTransportHealth(
        array $gateway,
        string $effectiveStatus,
        array $channelStatus,
        array $fallbackSummary,
        ?string $lastSyncAt
    ): array
    {
        $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
            ? (array)$metadata['control_plane_status']
            : [];
        $brokerState = (string)($channelStatus['broker']['state'] ?? self::STATUS_OFFLINE);
        $affectedRelayCount = count((array)($fallbackSummary['affected_relays'] ?? []));
        $transportState = $effectiveStatus;
        if ($effectiveStatus !== self::STATUS_OFFLINE && ($brokerState === self::STATUS_DEGRADED || $affectedRelayCount > 0)) {
            $transportState = self::STATUS_DEGRADED;
        }

        return [
            'status' => $transportState,
            'broker_connected' => !empty($channelStatus['broker']['connected']),
            'affected_relay_count' => $affectedRelayCount,
            'summary' => $affectedRelayCount > 0
                ? $affectedRelayCount . ' relæ(er) kører via cloud fallback'
                : (!empty($channelStatus['broker']['connected'])
                    ? 'Broker fast path er aktiv med API polling som fallback'
                    : 'API polling er aktiv som primær kontrolkanal'),
            'recommended_action' => $fallbackSummary['recommended_action'] ?? ($channelStatus['broker']['last_error'] ?? null),
            'last_successful_sync_at' => $lastSyncAt,
            'last_transport_failure_at' => isset($controlPlaneStatus['last_transport_failure_at'])
                ? (string)$controlPlaneStatus['last_transport_failure_at']
                : null,
            'last_transport_error' => isset($controlPlaneStatus['last_transport_error'])
                ? (string)$controlPlaneStatus['last_transport_error']
                : null,
        ];
    }

    private static function buildVersionDriftSummary(array $gateway): array
    {
        $installed = isset($gateway['installed_version']) ? trim((string)$gateway['installed_version']) : '';
        $target = isset($gateway['target_version']) ? trim((string)$gateway['target_version']) : '';
        $isDrifted = $installed !== '' && $target !== '' && $installed !== $target;

        return [
            'installed_version' => $installed !== '' ? $installed : null,
            'target_version' => $target !== '' ? $target : null,
            'release_channel' => isset($gateway['release_channel']) ? (string)$gateway['release_channel'] : self::configuredDefaultReleaseChannel(),
            'is_drifted' => $isDrifted,
            'status' => $isDrifted ? 'UPDATE_AVAILABLE' : (($installed === '' || $target === '') ? 'UNKNOWN' : 'IN_SYNC'),
            'last_successful_update_at' => $gateway['operational_snapshot']['last_successful_update_at'] ?? null,
        ];
    }

    private static function buildCredentialFreshnessSummary(array $gateway, ?int $now = null): array
    {
        $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $rotatedAt = isset($metadata['credentials_rotated_at']) ? (string)$metadata['credentials_rotated_at'] : null;
        $ageSeconds = self::heartbeatAgeSeconds($rotatedAt, $now);

        return [
            'rotated_at' => $rotatedAt,
            'age_days' => $ageSeconds === null ? null : (int)floor($ageSeconds / 86400),
            'state' => $rotatedAt === null
                ? 'UNKNOWN'
                : ($ageSeconds !== null && $ageSeconds <= self::CREDENTIAL_FRESH_AFTER_SECONDS ? 'FRESH' : 'STALE'),
        ];
    }

    /**
     * @return array<int,array<string,string>>
     */
    private static function defaultGatewayServiceHealth(string $defaultStatus): array
    {
        return [
            ['name' => 'edge-agent', 'status' => $defaultStatus],
            ['name' => 'lan-worker', 'status' => $defaultStatus],
            ['name' => 'redis', 'status' => $defaultStatus],
            ['name' => 'mariadb', 'status' => $defaultStatus],
            ['name' => 'minio', 'status' => $defaultStatus],
            ['name' => 'auto-updater', 'status' => $defaultStatus],
        ];
    }

    private static function buildContainerHealthSummary(array $gateway, string $effectiveStatus): array
    {
        $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $raw = isset($metadata['container_health']) && is_array($metadata['container_health'])
            ? (array)$metadata['container_health']
            : [];
        $rawServices = isset($raw['services']) && is_array($raw['services']) ? (array)$raw['services'] : [];
        $defaultServices = self::defaultGatewayServiceHealth(
            $effectiveStatus === self::STATUS_OFFLINE ? 'offline' : 'healthy'
        );
        $services = $defaultServices;
        foreach ($rawServices as $rawService) {
            if (!is_array($rawService)) {
                continue;
            }

            $serviceName = trim((string)($rawService['name'] ?? ''));
            if ($serviceName === '') {
                continue;
            }

            $matched = false;
            foreach ($services as $index => $defaultService) {
                if ((string)($defaultService['name'] ?? '') !== $serviceName) {
                    continue;
                }

                $services[$index] = array_merge($defaultService, $rawService);
                $matched = true;
                break;
            }

            if (!$matched) {
                $services[] = $rawService;
            }
        }
        $healthyCount = 0;
        $degradedCount = 0;
        foreach ($services as $index => $service) {
            if (!is_array($service)) {
                $services[$index] = ['name' => 'service-' . $index, 'status' => 'unknown'];
                continue;
            }

            $status = strtolower(trim((string)($service['status'] ?? 'unknown')));
            $name = trim((string)($service['name'] ?? 'service-' . $index));
            if (in_array($status, ['healthy', 'running', 'online'], true)) {
                $status = 'healthy';
                $healthyCount += 1;
            } elseif (in_array($status, ['degraded', 'starting', 'unknown'], true)) {
                $status = 'degraded';
                $degradedCount += 1;
            } else {
                $status = $status === 'offline' ? 'offline' : 'degraded';
                $degradedCount += 1;
            }

            $services[$index] = array_merge($service, [
                'name' => $name,
                'status' => $status,
            ]);
        }

        $state = $effectiveStatus === self::STATUS_OFFLINE
            ? self::STATUS_OFFLINE
            : ($degradedCount > 0 ? self::STATUS_DEGRADED : self::STATUS_ONLINE);

        return [
            'state' => strtoupper((string)($raw['state'] ?? $state)),
            'summary' => (string)($raw['summary'] ?? sprintf('%d/%d containers healthy', $healthyCount, count($services))),
            'services' => $services,
            'healthy_count' => $healthyCount,
            'total' => count($services),
        ];
    }

    private static function buildOutboxStatusSummary(array $gateway, ?int $now = null): array
    {
        $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $raw = isset($metadata['outbox_status']) && is_array($metadata['outbox_status'])
            ? (array)$metadata['outbox_status']
            : [];
        $queued = max(0, (int)($raw['queued'] ?? $raw['queue_depth'] ?? 0));
        $oldestQueuedAt = isset($raw['oldest_queued_at']) ? (string)$raw['oldest_queued_at'] : null;
        $oldestAgeSeconds = self::heartbeatAgeSeconds($oldestQueuedAt, $now);
        $state = $queued === 0
            ? 'IN_SYNC'
            : (($oldestAgeSeconds !== null && $oldestAgeSeconds >= self::HEARTBEAT_OFFLINE_AFTER_SECONDS) ? 'DEGRADED' : 'QUEUED');

        return [
            'state' => (string)($raw['state'] ?? $state),
            'queued' => $queued,
            'oldest_queued_at' => $oldestQueuedAt,
            'oldest_age_seconds' => $oldestAgeSeconds,
            'last_replayed_at' => isset($raw['last_replayed_at']) ? (string)$raw['last_replayed_at'] : null,
            'summary' => (string)($raw['summary'] ?? ($queued === 0 ? 'Outbox is empty' : sprintf('%d outbound items queued', $queued))),
        ];
    }

    private static function resolveLastSyncAt(array $gateway): ?string
    {
        $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $controlPlaneStatus = isset($metadata['control_plane_status']) && is_array($metadata['control_plane_status'])
            ? (array)$metadata['control_plane_status']
            : [];
        $outbox = isset($gateway['outbox_status']) && is_array($gateway['outbox_status'])
            ? (array)$gateway['outbox_status']
            : [];
        $outboxMetadata = isset($metadata['outbox_status']) && is_array($metadata['outbox_status'])
            ? (array)$metadata['outbox_status']
            : [];

        return self::latestTimestamp([
            $controlPlaneStatus['last_successful_sync_at'] ?? null,
            $metadata['last_sync_at'] ?? null,
            $outbox['last_replayed_at'] ?? null,
            $outboxMetadata['last_replayed_at'] ?? null,
            $gateway['last_heartbeat_at'] ?? null,
        ]);
    }

    private static function buildUpdateWindowSummary(array $gateway): array
    {
        $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $window = trim((string)($metadata['update_window'] ?? self::configuredDefaultUpdateWindow()));
        if ($window === '') {
            $window = self::configuredDefaultUpdateWindow();
        }

        return [
            'window' => $window,
            'timezone' => isset($metadata['timezone']) ? (string)$metadata['timezone'] : null,
            'strategy' => 'nightly',
        ];
    }

    private static function buildStagedVersionSummary(array $gateway): ?array
    {
        $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $raw = isset($metadata['staged_version']) && is_array($metadata['staged_version'])
            ? (array)$metadata['staged_version']
            : [];
        $targetVersion = trim((string)($raw['target_version'] ?? $raw['version'] ?? ''));
        if ($targetVersion === '') {
            return null;
        }

        return [
            'target_version' => $targetVersion,
            'staged_at' => isset($raw['staged_at']) ? (string)$raw['staged_at'] : null,
            'apply_after' => isset($raw['apply_after']) ? (string)$raw['apply_after'] : null,
            'status' => isset($raw['status']) ? (string)$raw['status'] : 'STAGED',
        ];
    }

    private static function buildRollbackStatusSummary(array $gateway): array
    {
        $metadata = isset($gateway['metadata']) && is_array($gateway['metadata']) ? (array)$gateway['metadata'] : [];
        $raw = isset($metadata['rollback_status']) && is_array($metadata['rollback_status'])
            ? (array)$metadata['rollback_status']
            : [];
        $state = trim((string)($raw['state'] ?? 'IDLE'));

        return [
            'state' => $state !== '' ? $state : 'IDLE',
            'reason' => isset($raw['reason']) ? (string)$raw['reason'] : null,
            'rolled_back_to' => isset($raw['rolled_back_to']) ? (string)$raw['rolled_back_to'] : null,
            'at' => isset($raw['at']) ? (string)$raw['at'] : null,
        ];
    }

    private static function buildGatewayDiagnostics(array $gateway, string $effectiveStatus, ?int $now = null): array
    {
        $diagnostics = [];
        $heartbeatAge = self::heartbeatAgeSeconds(
            isset($gateway['last_heartbeat_at']) ? (string)$gateway['last_heartbeat_at'] : null,
            $now
        );

        if ($effectiveStatus === self::STATUS_OFFLINE) {
            $diagnostics[] = [
                'code' => edge_gateway_operation_service::ERROR_OFFLINE,
                'severity' => 'danger',
                'message' => 'Gateway heartbeat has expired and the gateway is offline.',
                'recommended_action' => 'restart_agent',
            ];
        } elseif ($effectiveStatus === self::STATUS_DEGRADED || ($heartbeatAge !== null && $heartbeatAge >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS)) {
            $diagnostics[] = [
                'code' => edge_gateway_operation_service::ERROR_STALE_HEARTBEAT,
                'severity' => 'warning',
                'message' => 'Gateway heartbeat is stale and control traffic may degrade.',
                'recommended_action' => 'inspect_connectivity',
            ];
        }

        $activeOperation = isset($gateway['active_operation']) && is_array($gateway['active_operation'])
            ? (array)$gateway['active_operation']
            : null;
        if ($activeOperation !== null && !empty($activeOperation['started_at'])) {
            $startedAt = self::parseApplicationDateTime((string)$activeOperation['started_at']);
            if ($startedAt !== null && (($now ?? time()) - $startedAt) >= edge_gateway_operation_service::OPERATION_TIMEOUT_SECONDS) {
                $diagnostics[] = [
                    'code' => edge_gateway_operation_service::ERROR_OPERATION_TIMEOUT,
                    'severity' => 'warning',
                    'message' => 'The active gateway operation has exceeded the expected timeout.',
                    'recommended_action' => 'retry_operation',
                ];
            }
        }

        if (!empty($gateway['version_drift']['is_drifted'])) {
            $diagnostics[] = [
                'code' => 'EDGE_GATEWAY_VERSION_DRIFT',
                'severity' => 'info',
                'message' => 'Installed gateway version differs from the target version.',
                'recommended_action' => 'queue_update',
            ];
        }

        if (($gateway['credential_freshness']['state'] ?? 'UNKNOWN') === 'STALE') {
            $diagnostics[] = [
                'code' => 'EDGE_GATEWAY_CREDENTIALS_STALE',
                'severity' => 'warning',
                'message' => 'Gateway credentials have not been rotated recently.',
                'recommended_action' => 'rotate_credentials',
            ];
        }

        $containerHealth = isset($gateway['container_health']) && is_array($gateway['container_health'])
            ? (array)$gateway['container_health']
            : [];
        $containerState = strtoupper((string)($containerHealth['state'] ?? self::STATUS_ONLINE));
        if (in_array($containerState, [self::STATUS_DEGRADED, self::STATUS_OFFLINE], true)) {
            $diagnostics[] = [
                'code' => 'EDGE_GATEWAY_CONTAINER_DEGRADED',
                'severity' => $containerState === self::STATUS_OFFLINE ? 'danger' : 'warning',
                'message' => 'One or more compose services are not healthy on the gateway.',
                'recommended_action' => 'restart_agent',
            ];
        }

        $outboxStatus = isset($gateway['outbox_status']) && is_array($gateway['outbox_status'])
            ? (array)$gateway['outbox_status']
            : [];
        if ((int)($outboxStatus['queued'] ?? 0) > 0) {
            $diagnostics[] = [
                'code' => 'EDGE_GATEWAY_OUTBOX_BACKLOG',
                'severity' => 'warning',
                'message' => 'The gateway has queued outbound control-plane items waiting for replay.',
                'recommended_action' => 'inspect_connectivity',
            ];
        }

        $rollbackStatus = isset($gateway['rollback_status']) && is_array($gateway['rollback_status'])
            ? (array)$gateway['rollback_status']
            : [];
        $rollbackState = strtoupper((string)($rollbackStatus['state'] ?? 'IDLE'));
        if ($rollbackState === 'ROLLED_BACK') {
            $diagnostics[] = [
                'code' => 'EDGE_GATEWAY_UPDATE_ROLLED_BACK',
                'severity' => 'warning',
                'message' => 'The last container rollout was rolled back automatically.',
                'recommended_action' => 'review_diagnostics',
            ];
        } elseif ($rollbackState === 'FAILED') {
            $diagnostics[] = [
                'code' => 'EDGE_GATEWAY_ROLLBACK_FAILED',
                'severity' => 'danger',
                'message' => 'Gateway rollback failed and manual intervention is required.',
                'recommended_action' => 'review_diagnostics',
            ];
        }

        return $diagnostics;
    }

    private static function primaryGatewayErrorState(array $diagnostics, array $gateway): ?array
    {
        if ($diagnostics !== []) {
            return [
                'code' => (string)$diagnostics[0]['code'],
                'message' => (string)$diagnostics[0]['message'],
                'recommended_action' => $diagnostics[0]['recommended_action'] ?? null,
            ];
        }

        $activeStatus = strtoupper((string)($gateway['active_operation']['status'] ?? ''));
        if (in_array($activeStatus, [
            edge_gateway_operation_service::STATUS_CANCEL_REQUESTED,
            edge_gateway_operation_service::STATUS_CANCELLED,
        ], true)) {
            return null;
        }

        if (!empty($gateway['active_operation']['error_code']) || !empty($gateway['active_operation']['error_message'])) {
            return [
                'code' => $gateway['active_operation']['error_code'] ?? 'EDGE_GATEWAY_OPERATION_FAILED',
                'message' => $gateway['active_operation']['error_message'] ?? 'Gateway operation failed',
                'recommended_action' => 'retry_operation',
            ];
        }

        return null;
    }

    private static function findLatestCompletionTimestamp(array $jobs, ?string $commandType = null): ?string
    {
        foreach ($jobs as $job) {
            if (!is_array($job)) {
                continue;
            }
            if (($job['status'] ?? null) !== 'COMPLETED') {
                continue;
            }
            if ($commandType !== null && ($job['command_type'] ?? null) !== $commandType) {
                continue;
            }
            return isset($job['completed_at']) ? (string)$job['completed_at'] : null;
        }

        return null;
    }

    private static function findLatestShellTimestamp(array $sessions): ?string
    {
        foreach ($sessions as $session) {
            if (!is_array($session)) {
                continue;
            }
            foreach (['opened_at', 'approved_at', 'created_at'] as $field) {
                if (!empty($session[$field])) {
                    return (string)$session[$field];
                }
            }
        }

        return null;
    }

    /**
     * @param array<int,mixed> $timestamps
     */
    private static function latestTimestamp(array $timestamps): ?string
    {
        $latestValue = null;
        $latestEpoch = 0;

        foreach ($timestamps as $timestamp) {
            if (!is_string($timestamp) || trim($timestamp) === '') {
                continue;
            }

            $epoch = self::parseApplicationDateTime($timestamp);
            if ($epoch === null) {
                continue;
            }

            if ($latestValue === null || $epoch >= $latestEpoch) {
                $latestValue = $timestamp;
                $latestEpoch = $epoch;
            }
        }

        return $latestValue;
    }

    private static function normalizeFallbackMode(?string $fallbackMode): string
    {
        $normalized = strtoupper(trim((string)$fallbackMode));
        if (in_array($normalized, [
            self::RELAY_FALLBACK_PREFER_LOCAL,
            self::RELAY_FALLBACK_LOCAL_ONLY,
            self::RELAY_FALLBACK_CLOUD_ONLY,
        ], true)) {
            return $normalized;
        }

        return self::RELAY_FALLBACK_PREFER_LOCAL;
    }

    private function clearObjectPropertyCache(string $table, int $id): void
    {
        if ($id <= 0 || !defined('redis')) {
            return;
        }

        $normalizedTable = trim($table, " `\t\n\r\0\x0B");
        redis->clear_keys('obj_prop:' . $normalizedTable . ':' . $id . ':*');
    }

    private static function resolveDeviceFreshnessState(?array $device, ?int $ageSeconds): string
    {
        if ($device === null) {
            return 'MISSING';
        }
        if (isset($device['online']) && $device['online'] === false) {
            return 'OFFLINE';
        }
        if ($ageSeconds === null) {
            return 'UNKNOWN';
        }
        if ($ageSeconds >= self::DEVICE_FRESHNESS_DEGRADED_AFTER_SECONDS) {
            return 'STALE';
        }

        return 'READY';
    }

    public static function resolveGatewayStatus(?string $reportedStatus, ?string $lastHeartbeatAt, ?int $now = null): string
    {
        $normalizedStatus = self::normalizeGatewayStatus($reportedStatus);
        $heartbeatAgeSeconds = self::heartbeatAgeSeconds($lastHeartbeatAt, $now);

        if ($normalizedStatus === self::STATUS_OFFLINE) {
            return self::STATUS_OFFLINE;
        }

        if ($heartbeatAgeSeconds === null || $heartbeatAgeSeconds >= self::HEARTBEAT_OFFLINE_AFTER_SECONDS) {
            return self::STATUS_OFFLINE;
        }

        if ($normalizedStatus === self::STATUS_DEGRADED) {
            return self::STATUS_DEGRADED;
        }

        if ($normalizedStatus === self::STATUS_ONLINE && $heartbeatAgeSeconds >= self::HEARTBEAT_DEGRADED_AFTER_SECONDS) {
            return self::STATUS_DEGRADED;
        }

        return $normalizedStatus;
    }

    public static function resolveDiscoveryStatus(?string $discoveryStatus, string $effectiveStatus): string
    {
        $normalizedStatus = trim(strtoupper((string)$discoveryStatus));

        if ($effectiveStatus === self::STATUS_OFFLINE && $normalizedStatus === 'READY') {
            return 'STALE';
        }

        return $normalizedStatus !== '' ? $normalizedStatus : 'UNKNOWN';
    }

    private static function normalizeGatewayStatus(?string $reportedStatus): string
    {
        $normalizedStatus = trim(strtoupper((string)$reportedStatus));

        if (in_array($normalizedStatus, [self::STATUS_ONLINE, self::STATUS_DEGRADED, self::STATUS_OFFLINE], true)) {
            return $normalizedStatus;
        }

        return $normalizedStatus !== '' ? $normalizedStatus : 'UNKNOWN';
    }

    private static function heartbeatAgeSeconds(?string $lastHeartbeatAt, ?int $now = null): ?int
    {
        if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') {
            return null;
        }

        $heartbeatTimestamp = self::parseApplicationDateTime($lastHeartbeatAt);
        if ($heartbeatTimestamp === null) {
            return null;
        }

        return max(0, ($now ?? time()) - $heartbeatTimestamp);
    }

    private static function heartbeatTimestamp(?string $lastHeartbeatAt): int
    {
        if ($lastHeartbeatAt === null || trim($lastHeartbeatAt) === '') {
            return 0;
        }

        return self::parseApplicationDateTime($lastHeartbeatAt) ?? 0;
    }

    private static function statusPriority(string $status): int
    {
        return match ($status) {
            self::STATUS_ONLINE => 3,
            self::STATUS_DEGRADED => 2,
            self::STATUS_OFFLINE => 1,
            default => 0,
        };
    }

    private function writeAudit(?int $gatewayId, ?int $departmentId, string $action, ?int $userId, array $context): void
    {
        (new edge_gateway_audit_logs_o())->add_object([
            'gateway_id' => $gatewayId,
            'department_id' => $departmentId,
            'action' => $action,
            'actor_user_id' => $userId,
            'actor_type' => $userId === null ? 'SYSTEM' : 'USER',
            'severity' => 'INFO',
            'context_json' => $context,
        ]);
    }

    private function buildSignedBrokerToken(array $payload): string
    {
        $body = self::base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES));
        $signature = self::base64UrlEncode(hash_hmac('sha256', $body, $this->brokerSessionSecret(), true));
        return $body . '.' . $signature;
    }

    /**
     * @return array<string,mixed>
     * @throws Exception
     */
    private function parseSignedBrokerToken(string $token): array
    {
        $token = trim($token);
        if ($token === '' || !str_contains($token, '.')) {
            throw new Exception('Missing broker session token');
        }

        [$body, $signature] = explode('.', $token, 2);
        $expectedSignature = self::base64UrlEncode(hash_hmac('sha256', $body, $this->brokerSessionSecret(), true));
        if (!hash_equals($expectedSignature, $signature)) {
            throw new Exception('Invalid broker session token');
        }

        $decoded = json_decode((string)self::base64UrlDecode($body), true);
        if (!is_array($decoded)) {
            throw new Exception('Broker session token payload is invalid');
        }

        $expiresAt = isset($decoded['exp']) ? (int)$decoded['exp'] : 0;
        if ($expiresAt > 0 && $expiresAt <= time()) {
            throw new Exception('Broker session token expired');
        }

        return $decoded;
    }

    private function findShellSessionByToken(string $plainToken): edge_gateway_shell_sessions_o
    {
        $tokenHash = $this->hashToken($plainToken);
        $rows = (new edge_gateway_shell_sessions_o())->getFieldsWhere([
            'session_token_hash' => $tokenHash,
            'deleted_at' => null,
        ], ['id']);

        $sessionId = isset($rows[0]['id']) ? (int)$rows[0]['id'] : 0;
        $session = (new edge_gateway_shell_sessions_o())->select($sessionId);
        if (!$session->exists()) {
            throw new Exception('Shell session not found');
        }

        return $session;
    }

    private function brokerSessionSecret(): string
    {
        $secret = trim((string)(getenv('EDGE_GATEWAY_SESSION_SECRET') ?: ''));
        if ($secret !== '') {
            return $secret;
        }

        $fallback = $this->configuredBrokerSharedSecret();
        if ($fallback !== '') {
            return $fallback;
        }

        return hash('sha256', $this->getApiBaseUrl() . '::edgegateway');
    }

    private static function base64UrlEncode(string $value): string
    {
        return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
    }

    private static function base64UrlDecode(string $value): string
    {
        $padding = strlen($value) % 4;
        if ($padding > 0) {
            $value .= str_repeat('=', 4 - $padding);
        }

        return (string)base64_decode(strtr($value, '-_', '+/'));
    }

    private function hashToken(string $plainToken): string
    {
        return hash('sha256', $plainToken);
    }

    public static function parseApplicationDateTime(?string $value): ?int
    {
        $normalized = trim((string)$value);
        if ($normalized === '') {
            return null;
        }

        $timezone = self::applicationTimeZone();
        $dateTime = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $normalized, $timezone);
        if ($dateTime instanceof \DateTimeImmutable) {
            return $dateTime->getTimestamp();
        }

        try {
            return (new \DateTimeImmutable($normalized, $timezone))->getTimestamp();
        } catch (\Throwable) {
            return null;
        }
    }

    public static function formatApplicationDateTime(int $timestamp): string
    {
        return (new \DateTimeImmutable('@' . $timestamp))
            ->setTimezone(self::applicationTimeZone())
            ->format('Y-m-d H:i:s');
    }

    private static function applicationTimeZone(): \DateTimeZone
    {
        $timezone = trim((string)($_ENV['CONFIG_TIMEZONE'] ?? getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen'));
        if ($timezone === '') {
            $timezone = 'Europe/Copenhagen';
        }

        try {
            return new \DateTimeZone($timezone);
        } catch (\Throwable) {
            return new \DateTimeZone('Europe/Copenhagen');
        }
    }

    private function now(): string
    {
        return self::formatApplicationDateTime(time());
    }

    private function formatDateTime(int $timestamp): string
    {
        return self::formatApplicationDateTime($timestamp);
    }

    private function remoteIp(): ?string
    {
        $ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
        return $ip !== '' ? $ip : null;
    }
}
