<?php

namespace modules\selfserve\traits;
require_once WD . '/modules/selfserve/helpers/selfserve_lane_relay.php';
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';

use classes\edge_gateway_manager;
use classes\shelly;
use classes\shelly_transport_resolver;
use interfaces\shelly_transport_i;
use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\shelly\helpers\shelly_request_body_get_states;
use objects\selfserve_wash_sessions_o;

trait selfserve_lane_relay_controller_t
{
    private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20;
    private const SHELLY_RETRY_SLEEP_MICROSECONDS = 250000;
    private const SHELLY_STATUS_SNAPSHOT_TTL_SECONDS = 1;
    private const SHELLY_STATUS_SNAPSHOT_KEY_PREFIX = 'selfserve_lane_shelly_status_snapshot_';
    private const SHELLY_DEFAULT_CHANNEL = 0;
    private const DEMO_RELAY_ID_PREFIX = 'demo-';

    protected ?string $shelly_transport_override = null;

    /**
     * @throws \Exception
     */
    public function setShellyTransportOverride(?string $transport): self
    {
        $normalized = strtolower(trim((string)$transport));
        if ($normalized === '') {
            $this->shelly_transport_override = null;
            return $this;
        }

        if (!in_array($normalized, ['cloud', 'gateway', 'local'], true)) {
            throw new \Exception('Invalid Shelly transport override. Expected cloud, gateway, or local.');
        }

        $this->shelly_transport_override = $normalized;
        return $this;
    }

    /**
     * Get current MACHINE relay status from Shelly.
     * @return array{relay_id: string, online: bool, on: bool, status: array<string,array<string,bool>>, binding?: array, execution?: array, raw?: array}
     * @throws \Exception
     */
    public function getMachineRelayStatus(): array
    {
        return $this->getRelayStatus(selfserve_lane_relay::MACHINE);
    }

    /**
     * Get current MACHINE_PROGRAM_PICKER relay status from Shelly.
     * @return array{relay_id: string, online: bool, on: bool, status: array<string,array<string,bool>>, binding?: array, execution?: array, raw?: array}
     * @throws \Exception
     */
    public function getMachineProgramPickerRelayStatus(): array
    {
        return $this->getRelayStatus(selfserve_lane_relay::MACHINE_PROGRAM_PICKER);
    }

    /**
     * Get current MACHINE_CLEANER relay status from Shelly.
     * @return array{relay_id: string, online: bool, on: bool, status: array<string,array<string,bool>>, binding?: array, execution?: array, raw?: array}
     * @throws \Exception
     */
    public function getMachineCleanerRelayStatus(): array
    {
        return $this->getRelayStatus(selfserve_lane_relay::MACHINE_CLEANER);
    }

    /**
     * Get current relay status from Shelly for a specific relay type.
     * @param selfserve_lane_relay $relay
     * @return array{relay_id: string, online: bool, on: bool, status: array<string,array<string,bool>>, binding?: array, execution?: array, raw?: array}
     * @throws \Exception
     */
    public function getRelayStatus(selfserve_lane_relay $relay): array
    {
        $relay_id = $this->getRelayId($relay);
        $snapshot = $this->getLaneShellyStatusSnapshot([$relay_id]);
        if (!array_key_exists($relay_id, $snapshot)) {
            throw new \Exception("Shelly did not return a status entry for {$relay->name} relay");
        }

        $device = $snapshot[$relay_id];
        $on = $this->extractRelayOnState($device, $relay);
        $result = [
            'relay_id' => $relay_id,
            'online' => $this->extractRelayOnlineState($device),
            'on' => $on,
            'status' => ['switch:0' => ['output' => $on]],
        ];

        foreach (['binding', 'execution', 'raw'] as $diagnostic_key) {
            $diagnostic_value = $this->getPayloadValue($device, $diagnostic_key);
            if (is_array($diagnostic_value) && $diagnostic_value !== []) {
                $result[$diagnostic_key] = $diagnostic_value;
            }
        }

        return $result;
    }

    /**
     * Set MACHINE relay status using the same guards as manual relay controls.
     * @param bool $on true to turn on, false to turn off
     * @return bool
     * @throws \Exception
     */
    public function setMachineRelayStatus(bool $on): bool
    {
        return $on
            ? $this->turnOnRelay(selfserve_lane_relay::MACHINE)
            : $this->turnOffRelay(selfserve_lane_relay::MACHINE);
    }

    /**
     * Set MACHINE relay status directly, bypassing lane status guards.
     * Intended for department-level operational toggles.
     * @throws \Exception
     */
    public function setMachineRelayStatusHard(bool $on): bool
    {
        return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
    }

    /**
     * Set PROGRAM SELECTOR relay status directly, bypassing lane status guards.
     * Intended for department-level operational toggles.
     * @throws \Exception
     */
    public function setProgramSelectorRelayStatusHard(bool $on): bool
    {
        return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
    }

    /**
     * Set MACHINE_PROGRAM_PICKER relay status directly.
     * @param bool $on true to turn on, false to turn off
     * @return bool
     * @throws \Exception
     */
    public function setMachineProgramPickerRelayStatus(bool $on): bool
    {
        return $this->setRelayStatus(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
    }

    /**
     * Set MACHINE_PROGRAM_PICKER relay status directly, bypassing lane status guards.
     * Intended for department-level operational toggles.
     * @throws \Exception
     */
    public function setMachineProgramPickerRelayStatusHard(bool $on): bool
    {
        return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
    }

    /**
     * Set MACHINE_CLEANER relay status directly.
     * @param bool $on true to turn on, false to turn off
     * @return bool
     * @throws \Exception
     */
    public function setMachineCleanerRelayStatus(bool $on): bool
    {
        return $this->setRelayStatus(selfserve_lane_relay::MACHINE_CLEANER, $on);
    }

    /**
     * Set MACHINE_CLEANER relay status directly, bypassing lane status guards.
     * Intended for department-level operational toggles.
     * @throws \Exception
     */
    public function setMachineCleanerRelayStatusHard(bool $on): bool
    {
        return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_CLEANER, $on);
    }

    /**
     * Set relay status directly for a specific relay type.
     * @param selfserve_lane_relay $relay
     * @param bool $on true to turn on, false to turn off
     * @return bool
     * @throws \Exception
     */
    public function setRelayStatus(selfserve_lane_relay $relay, bool $on): bool
    {
        return $on
            ? $this->forceTurnOnRelay($relay)
            : $this->forceTurnOffRelay($relay);
    }

    /**
     * Set relay status directly for a specific relay type, bypassing lane status guards.
     * @throws \Exception
     */
    public function setRelayStatusHard(selfserve_lane_relay $relay, bool $on): bool
    {
        return $this->sendRelaySwitchCommand($relay, $on);
    }

    /**
     * Synchronize MACHINE relay state from currently visible task services.
     *
     * - Always persists normalized services in lane cache.
     * - Enables MACHINE only when visible and allowEnable=true (guarded path).
     * - Disables MACHINE immediately when not visible (hard OFF path).
     *
     * @param array<int,mixed> $allowedServices
     * @return array{
     *   machine_visible: bool,
     *   relay_action: string,
     *   relay_target_on: bool
     * }
     * @throws \Exception
     */
    public function syncMachineRelayFromVisibleServices(array $allowedServices, bool $allowEnable = true): array
    {
        $normalizedServices = $this->normalizeVisibleServiceNames($allowedServices);
        $this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $normalizedServices);

        $machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true);
        $relayAction = 'noop';

        if (!$this->isDepartmentSelfServeRelayMutationsEnabled()) {
            return [
                'machine_visible' => $machineVisible,
                'relay_action' => 'noop_selfserve_disabled',
                'relay_target_on' => $machineVisible,
            ];
        }

        if (!$this->hasConfiguredRelay(selfserve_lane_relay::MACHINE)) {
            return [
                'machine_visible' => $machineVisible,
                'relay_action' => 'noop_missing_machine_relay',
                'relay_target_on' => $machineVisible,
            ];
        }

        if ($machineVisible) {
            if ($allowEnable) {
                $this->turnOnRelay(selfserve_lane_relay::MACHINE);
                $relayAction = 'enabled';
            } else {
                $relayAction = 'noop_enable_blocked';
            }
        } else {
            $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, false);
            $relayAction = 'disabled';
        }

        return [
            'machine_visible' => $machineVisible,
            'relay_action' => $relayAction,
            'relay_target_on' => $machineVisible,
        ];
    }

    /**
     * Persist the services currently visible to the user without mutating hardware.
     *
     * The user wash start flow calls this before the user confirms lane and wash type.
     * Hardware activation remains owned by START / explicit relay endpoints.
     *
     * @param array<int,mixed> $allowedServices
     * @return array{
     *   machine_visible: bool,
     *   relay_action: string,
     *   relay_target_on: bool
     * }
     */
    public function setAllowedServicesFromVisibleTasks(array $allowedServices): array
    {
        $normalizedServices = $this->normalizeVisibleServiceNames($allowedServices);
        $this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, $normalizedServices);

        $machineVisible = in_array(selfserve_lane_services::MACHINE->name, $normalizedServices, true);

        return [
            'machine_visible' => $machineVisible,
            'relay_action' => 'cache_only',
            'relay_target_on' => $machineVisible,
        ];
    }

    /**
     * @param array<int,mixed> $services
     * @return string[]
     */
    private function normalizeVisibleServiceNames(array $services): array
    {
        $normalized = [];
        foreach ($services as $service) {
            $name = strtoupper(trim((string)$service));
            if ($name === '') {
                continue;
            }
            if (!in_array($name, $normalized, true)) {
                $normalized[] = $name;
            }
        }
        return $normalized;
    }

    private function hasConfiguredRelay(selfserve_lane_relay $relay): bool
    {
        try {
            $relayId = trim($this->getRelayId($relay));
            return $relayId !== '';
        } catch (\Throwable) {
            return false;
        }
    }

    private function isDepartmentSelfServeRelayMutationsEnabled(): bool
    {
        if (!method_exists($this, 'isDepartmentSelfServeEnabled')) {
            return true;
        }

        try {
            return $this->isDepartmentSelfServeEnabled() === true;
        } catch (\Throwable) {
            return false;
        }
    }

    /**
     * Resolve relay ID for the current lane.
     * @param selfserve_lane_relay $relay
     * @throws \Exception
     */
    private function getRelayId(selfserve_lane_relay $relay): string
    {
        if (empty($this->department_lane)) {
            throw new \Exception("Department lane object not found for lane ID {$this->id}");
        }

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

        if ($relay_id === '') {
            throw new \Exception("Invalid relay ID for {$relay->name} relay");
        }

        return $relay_id;
    }

    /**
     * @return string[]
     * @throws \Exception
     */
    private function getConfiguredLaneRelayIds(): array
    {
        if (empty($this->department_lane)) {
            throw new \Exception("Department lane object not found for lane ID {$this->id}");
        }

        $ids = [
            (string)$this->department_lane->relay_machine_id->value(),
            (string)$this->department_lane->relay_machine_program_picker_id->value(),
            (string)$this->department_lane->relay_machine_cleaner_id->value(),
        ];
        $ids = array_values(array_unique(array_filter(array_map('trim', $ids), static fn(string $id): bool => $id !== '')));

        if (count($ids) < 1) {
            throw new \Exception('No configured Shelly relay IDs for this lane');
        }

        return $ids;
    }

    /**
     * @param string[] $required_relay_ids
     * @return array<string,array>
     * @throws \Exception
     */
    private function getLaneShellyStatusSnapshot(array $required_relay_ids): array
    {
        $cached = $this->getCachedLaneShellyStatusSnapshot();
        if ($cached !== null && $this->snapshotHasUsablePayload($cached, $required_relay_ids)) {
            return $cached;
        }

        $fresh = $this->fetchLaneShellyStatusSnapshotWithReadiness($required_relay_ids);
        $this->setCachedLaneShellyStatusSnapshot($fresh);
        return $fresh;
    }

    /**
     * @param string[] $required_relay_ids
     * @return array<string,array>
     * @throws \Exception
     */
    private function fetchLaneShellyStatusSnapshotWithReadiness(array $required_relay_ids): array
    {
        $relay_ids = $this->getConfiguredLaneRelayIds();
        $cloud_relay_ids = array_values(array_filter(
            $relay_ids,
            fn(string $relay_id): bool => !$this->isDemoRelayId($relay_id)
        ));
        $demo_relay_ids = array_values(array_filter(
            $relay_ids,
            fn(string $relay_id): bool => $this->isDemoRelayId($relay_id)
        ));

        if (count($cloud_relay_ids) < 1) {
            return $this->appendDemoRelaySnapshots([], $relay_ids);
        }

        $deadline = $this->nowTimestamp() + self::SHELLY_STATUS_WAIT_TIMEOUT_SECONDS;
        $last_reason = 'Shelly relay status is not ready yet';

        do {
            $result = $this->sendShellyPost('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids));
            $snapshot = $this->appendDemoRelaySnapshots(
                $this->mapResponseToRelaySnapshot($result),
                $demo_relay_ids
            );

            if ($this->snapshotHasUsablePayload($snapshot, $required_relay_ids)) {
                return $snapshot;
            }

            $last_reason = $this->describeShellyNotReadyReason($result, $snapshot, $required_relay_ids);
            if ($this->nowTimestamp() >= $deadline) {
                break;
            }
            $this->sleepMicroseconds(self::SHELLY_RETRY_SLEEP_MICROSECONDS);
        } while (true);

        throw new \Exception($last_reason);
    }

    /**
     * @param string[] $relay_ids
     */
    private function buildStatusGetPayload(array $relay_ids): array
    {
        $parameters = new shelly_request_body_get_states();
        $parameters->ids = $relay_ids;
        $parameters->select = ['status'];
        return (array)$parameters;
    }

    private function isDemoRelayId(string $relay_id): bool
    {
        $normalized = strtolower(trim($relay_id));
        return $normalized !== '' && str_starts_with($normalized, self::DEMO_RELAY_ID_PREFIX);
    }

    /**
     * @param array<string,array> $snapshot
     * @param string[] $relay_ids
     * @return array<string,array>
     */
    private function appendDemoRelaySnapshots(array $snapshot, array $relay_ids): array
    {
        foreach ($relay_ids as $relay_id) {
            if (!$this->isDemoRelayId($relay_id)) {
                continue;
            }
            $snapshot[$relay_id] = $this->getDemoRelaySnapshotEntry($relay_id);
        }

        return $snapshot;
    }

    /**
     * @return array{id: string, online: bool, on: bool, status: array<string,array<string,bool>>}
     */
    private function buildDemoRelaySnapshotEntry(string $relay_id, bool $on): array
    {
        return [
            'id' => $relay_id,
            'online' => true,
            'on' => $on,
            'status' => ['switch:0' => ['output' => $on]],
        ];
    }

    private function getDemoRelaySnapshotEntry(string $relay_id): array
    {
        $snapshot = $this->getCachedLaneShellyStatusSnapshot() ?? [];
        $entry = $snapshot[$relay_id] ?? null;
        if (is_array($entry) && $this->relayStatusPayloadExists($entry)) {
            return $entry;
        }

        return $this->buildDemoRelaySnapshotEntry($relay_id, false);
    }

    /**
     * @param array|object|null $response
     * @return array<string,array>
     */
    private function mapResponseToRelaySnapshot(array|object|null $response): array
    {
        $snapshot = [];
        foreach ($this->normalizeRelayDevicesResponse($response) as $device) {
            $id = isset($device['id']) ? trim((string)$device['id']) : '';
            if ($id === '') {
                continue;
            }
            $snapshot[$id] = $this->normalizeRelaySnapshotEntry($device);
        }
        return $snapshot;
    }

    /**
     * @param array|object|null $response
     * @return array<int,array>
     */
    private function normalizeRelayDevicesResponse(array|object|null $response): array
    {
        if ($response === null) {
            return [];
        }
        if (is_object($response)) {
            $response = [$response];
        }
        if (!is_array($response)) {
            return [];
        }

        $devices = [];
        foreach ($response as $item) {
            if (is_object($item)) {
                $item = $this->normalizeRelaySnapshotEntry($item);
            }
            if (!is_array($item)) {
                continue;
            }
            $devices[] = $this->normalizeRelaySnapshotEntry($item);
        }
        return $devices;
    }

    /**
     * @param array|object $device
     * @return array
     */
    private function normalizeRelaySnapshotEntry(array|object $device): array
    {
        $encoded = json_encode($device, JSON_UNESCAPED_UNICODE);
        if (!is_string($encoded)) {
            return [];
        }
        $decoded = json_decode($encoded, true);
        return is_array($decoded) ? $decoded : [];
    }

    /**
     * @param array<string,array> $snapshot
     * @param string[] $required_relay_ids
     */
    private function snapshotHasUsablePayload(array $snapshot, array $required_relay_ids): bool
    {
        foreach ($required_relay_ids as $relay_id) {
            if (!array_key_exists($relay_id, $snapshot)) {
                return false;
            }
            if (!$this->relayStatusPayloadExists($snapshot[$relay_id])) {
                return false;
            }
        }
        return true;
    }

    private function relayStatusPayloadExists(array $device): bool
    {
        $status = $this->getPayloadValue($device, 'status');
        if ($status !== null) {
            foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
                $switch_state = $this->getPayloadValue($status, $switch_key);
                if ($switch_state === null) {
                    continue;
                }
                if ($this->getPayloadValue($switch_state, 'output') !== null) {
                    return true;
                }
            }
        }

        return $this->getPayloadValue($device, 'on') !== null;
    }

    private function extractRelayOnlineState(array $device): bool
    {
        $online = $this->getPayloadValue($device, 'online');
        if ($online === null) {
            return false;
        }
        if (is_bool($online)) {
            return $online;
        }
        return (int)$online === 1;
    }

    /**
     * Extract boolean on/off status from Shelly switch payload.
     * @throws \Exception
     */
    private function extractRelayOnState(array $device, selfserve_lane_relay $relay): bool
    {
        $direct = $this->getPayloadValue($device, 'on');
        if ($direct !== null) {
            return (bool)$direct;
        }

        $status = $this->getPayloadValue($device, 'status');
        foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
            $switch_state = $this->getPayloadValue($status, $switch_key);
            if ($switch_state === null) {
                continue;
            }

            $output = $this->getPayloadValue($switch_state, 'output');
            if ($output !== null) {
                return (bool)$output;
            }
        }

        throw new \Exception("Unable to determine {$relay->name} relay state from Shelly status payload");
    }

    private function getPayloadValue(array|object|null $payload, string $key): mixed
    {
        if (is_array($payload)) {
            return $payload[$key] ?? null;
        }
        if (is_object($payload)) {
            return $payload->$key ?? null;
        }
        return null;
    }

    /**
     * @param array|object|null $response
     * @param array<string,array> $snapshot
     * @param string[] $required_relay_ids
     */
    private function describeShellyNotReadyReason(array|object|null $response, array $snapshot, array $required_relay_ids): string
    {
        $payload_text = strtolower($this->serializeShellyResponse($response));
        foreach (['rate limit', 'ratelimit', 'too many', '429', 'throttle', 'retry'] as $token) {
            if (str_contains($payload_text, $token)) {
                return 'Shelly rate limit reached, waiting for next available window';
            }
        }

        foreach ($required_relay_ids as $relay_id) {
            if (!array_key_exists($relay_id, $snapshot)) {
                return 'Shelly returned no device status yet';
            }
            if (!$this->relayStatusPayloadExists($snapshot[$relay_id])) {
                return 'Shelly relay status payload is not ready yet';
            }
        }

        return 'Shelly relay status is not ready yet';
    }

    private function serializeShellyResponse(array|object|null $response): string
    {
        if ($response === null) {
            return '';
        }
        if (is_scalar($response)) {
            return (string)$response;
        }
        $encoded = json_encode($response, JSON_UNESCAPED_UNICODE);
        return is_string($encoded) ? $encoded : '';
    }

    private function getLaneShellyStatusSnapshotCacheKey(): string
    {
        $transport = strtolower(trim((string)$this->shelly_transport_override));
        $safeTransport = $transport !== '' ? (preg_replace('/[^a-z0-9_-]+/', '_', $transport) ?: $transport) : '';
        $transportSuffix = $safeTransport !== '' ? '_' . $safeTransport : '';
        return self::SHELLY_STATUS_SNAPSHOT_KEY_PREFIX . (int)$this->id . $transportSuffix;
    }

    /**
     * @return array<string,array>|null
     */
    private function getCachedLaneShellyStatusSnapshot(): ?array
    {
        $redis = $this->redisFacade();
        if ($redis === null) {
            return null;
        }

        $raw = $redis->get($this->getLaneShellyStatusSnapshotCacheKey());
        if (!is_string($raw) || $raw === '') {
            return null;
        }

        $decoded = json_decode($raw, true);
        if (!is_array($decoded)) {
            return null;
        }

        $snapshot = [];
        foreach ($decoded as $relay_id => $entry) {
            $id = trim((string)$relay_id);
            if ($id === '' || !is_array($entry)) {
                continue;
            }
            $snapshot[$id] = $entry;
        }
        return $snapshot;
    }

    /**
     * @param array<string,array> $snapshot
     */
    private function setCachedLaneShellyStatusSnapshot(array $snapshot): void
    {
        $redis = $this->redisFacade();
        if ($redis === null) {
            return;
        }

        $encoded = json_encode($snapshot, JSON_UNESCAPED_UNICODE);
        if (!is_string($encoded)) {
            return;
        }
        $redis->setEx(
            $this->getLaneShellyStatusSnapshotCacheKey(),
            $encoded,
            self::SHELLY_STATUS_SNAPSHOT_TTL_SECONDS
        );
    }

    /**
     * @throws \Exception
     */
    protected function sendShellyPost(string $endpoint, array $payload): array|object|null
    {
        $transport = $this->createShellyTransport();
        $transport->requireModuleEnabled();
        $transport->requireValidSecretKey();
        return edge_gateway_manager::withRelayActionContext(
            $this->buildSelfServeRelayActionContext($endpoint, $payload),
            fn(): array|object|null => $transport->sendPostRequest($endpoint, $payload, $this->resolveShellyTransportDepartmentId())
        );
    }

    protected function createShellyClient(): shelly
    {
        return new shelly();
    }

    protected function createShellyTransport(): shelly_transport_i
    {
        return (new shelly_transport_resolver())->resolveForDepartment(
            $this->resolveShellyTransportDepartmentId(),
            $this->shelly_transport_override
        );
    }

    /**
     * @throws \Exception
     */
    protected function resolveShellyTransportDepartmentId(): int
    {
        if (empty($this->department_lane)) {
            throw new \Exception("Department lane object not found for lane ID {$this->id}");
        }

        $department_id = (int)$this->department_lane->department->value();
        if ($department_id <= 0) {
            throw new \Exception('Unable to resolve department for Shelly transport');
        }

        return $department_id;
    }

    protected function buildSelfServeRelayActionContext(string $endpoint, array $payload, array $overrides = []): array
    {
        $relayIds = $this->extractSelfServeRelayIdsFromShellyPayload($payload);
        $context = [
            'module' => 'selfserve',
            'reason' => $this->describeSelfServeRelayReason($endpoint, $payload),
            'lane_id' => (int)$this->id,
            'department_id' => $this->safeSelfServeDepartmentId(),
            'route' => $_SERVER['REQUEST_URI'] ?? null,
            'relay_ids' => $relayIds,
        ];

        if (count($relayIds) === 1) {
            $context['relay_id'] = $relayIds[0];
            $context['relay_role'] = $this->describeSelfServeRelayRole($relayIds[0]);
        }

        try {
            $customerNumber = method_exists($this, 'getCustomerNumber') ? $this->getCustomerNumber() : null;
            if ($customerNumber !== null && (int)$customerNumber > 0) {
                $context['customer_number'] = (int)$customerNumber;
            }
        } catch (\Throwable) {
        }

        try {
            $licensePlate = method_exists($this, 'getLicensePlate') ? trim((string)$this->getLicensePlate()) : '';
            if ($licensePlate !== '') {
                $context['license_plate'] = $licensePlate;
            }
        } catch (\Throwable) {
        }

        return array_replace_recursive(array_filter(
            $context,
            static fn(mixed $value): bool => $value !== null && $value !== '' && $value !== []
        ), $overrides);
    }

    /**
     * @return string[]
     */
    private function extractSelfServeRelayIdsFromShellyPayload(array $payload): array
    {
        $ids = [];
        foreach ((array)($payload['ids'] ?? []) as $id) {
            $id = trim((string)$id);
            if ($id !== '') {
                $ids[] = $id;
            }
        }

        $singleId = trim((string)($payload['id'] ?? $payload['relayId'] ?? $payload['relay_id'] ?? ''));
        if ($singleId !== '') {
            $ids[] = $singleId;
        }

        return array_values(array_unique($ids));
    }

    private function describeSelfServeRelayReason(string $endpoint, array $payload): string
    {
        if (str_contains(strtolower($endpoint), '/get')) {
            return 'Read self-serve relay status';
        }

        $target = array_key_exists('on', $payload) && (bool)$payload['on'] ? 'ON' : 'OFF';
        return 'Set self-serve relay ' . $target;
    }

    private function describeSelfServeRelayRole(string $relayId): ?string
    {
        foreach (selfserve_lane_relay::cases() as $relay) {
            try {
                if ($this->getRelayId($relay) === $relayId) {
                    return $relay->name;
                }
            } catch (\Throwable) {
            }
        }

        return null;
    }

    private function safeSelfServeDepartmentId(): ?int
    {
        try {
            return $this->resolveShellyTransportDepartmentId();
        } catch (\Throwable) {
            return null;
        }
    }

    protected function redisFacade(): mixed
    {
        return defined('redis') ? redis : null;
    }

    protected function nowTimestamp(): float
    {
        return microtime(true);
    }

    protected function sleepMicroseconds(int $microseconds): void
    {
        if ($microseconds > 0) {
            usleep($microseconds);
        }
    }

    /**
     * Turn on the lane relay
     * @param selfserve_lane_relay $relay The relay to turn on (MACHINE or MACHINE_PROGRAM_PICKER)
     * @parm int|null $duration The duration in seconds to keep the relay on (optional)
     * @return bool True if the relay was successfully turned on
     * @throws \Exception If an invalid relay is specified or if the lane is not in a state to turn on the relay
     */
    public function turnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool
    {
        if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane");

        if ($relay === selfserve_lane_relay::MACHINE) {
            $allowed = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
            if (!is_array($allowed) || !in_array(selfserve_lane_services::MACHINE->name, $allowed, true)) {
                throw new \Exception("MACHINE relay is not allowed to be enabled at this time");
            }
        }

        $result = $this->sendRelaySwitchCommand($relay, true, $duration);
        if ($result && $relay === selfserve_lane_relay::MACHINE) {
            $this->markLatestSelfServeSessionRelayEnabledForLane();
        }

        return $result;
    }

    protected function markLatestSelfServeSessionRelayEnabledForLane(): void
    {
        try {
            $customerNumber = (int)$this->getCustomerNumber();
            $session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
                (int)$this->id,
                $customerNumber > 0 ? $customerNumber : null
            );
            if (!$session->exists() && $customerNumber > 0) {
                $session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane((int)$this->id);
            }
            if (!$session->exists() || (bool)$session->machine_relay_enabled->value() === true) {
                return;
            }

            $session->markRelayEnabled();
        } catch (\Throwable $e) {
            error_log(
                'Failed to synchronize self-serve machine relay session state for lane '
                . (int)$this->id
                . ': '
                . $e->getMessage()
            );
        }
    }

    /**
     * Force turn on the MACHINE relay, bypassing allowed services gating.
     * Intended for superuser/emergency operations.
     * @param int|null $duration Optional auto-off duration in seconds
     * @return bool
     * @throws \Exception
     */
    public function forceTurnOnMachineRelay(?int $duration = null): bool
    {
        return $this->forceTurnOnRelay(selfserve_lane_relay::MACHINE, $duration);
    }

    /**
     * Force turn on a specific relay, bypassing allowed services gating.
     * @param selfserve_lane_relay $relay
     * @param int|null $duration Optional auto-off duration in seconds
     * @return bool
     * @throws \Exception
     */
    public function forceTurnOnRelay(selfserve_lane_relay $relay, ?int $duration = null): bool
    {
        if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn on relay on CLOSED lane");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn on relay on MAINTENANCE lane");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn on relay on FAULT lane");

        return $this->sendRelaySwitchCommand($relay, true, $duration);
    }

    /**
     * Force turn off the MACHINE relay, bypassing allowed services gating.
     * Intended for superuser/emergency operations.
     * @return bool
     * @throws \Exception
     */
    public function forceTurnOffMachineRelay(): bool
    {
        return $this->forceTurnOffRelay(selfserve_lane_relay::MACHINE);
    }

    /**
     * Force turn off a specific relay, bypassing allowed services gating.
     * @param selfserve_lane_relay $relay
     * @return bool
     * @throws \Exception
     */
    public function forceTurnOffRelay(selfserve_lane_relay $relay): bool
    {
        if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane");

        return $this->sendRelaySwitchCommand($relay, false);
    }

    /**
     * Turn off the lane relay
     * @param selfserve_lane_relay $relay The relay to turn off (MACHINE or MACHINE_PROGRAM_PICKER)
     * @return bool True if the relay was successfully turned off
     * @throws \Exception If an invalid relay is specified or if the lane is not in a state to turn off the relay
     */
    public function turnOffRelay(selfserve_lane_relay $relay): bool
    {
        if (empty($this->department_lane)) throw new \Exception("Department lane object not found for lane ID {$this->id}");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::CLOSED)) throw new \Exception("Cannot turn off relay on CLOSED lane");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::MAINTENANCE)) throw new \Exception("Cannot turn off relay on MAINTENANCE lane");
        if ($this->getLaneStatus()->equals(selfserve_lane_status::FAULT)) throw new \Exception("Cannot turn off relay on FAULT lane");

        return $this->sendRelaySwitchCommand($relay, false);
    }

    /**
     * @throws \Exception
     */
    private function sendRelaySwitchCommand(selfserve_lane_relay $relay, bool $on, ?int $duration = null): bool
    {
        if (!$this->isDepartmentSelfServeRelayMutationsEnabled()) {
            throw new \Exception("Cannot change relay state: Self-serve is not enabled for this lane's department.");
        }

        $relay_id = $this->getRelayId($relay);
        $payload = [
            'id' => $relay_id,
            'channel' => self::SHELLY_DEFAULT_CHANNEL,
            'on' => $on,
        ];
        if ($duration !== null && $duration > 0) {
            $payload['toggle_after'] = $duration;
        }

        $response = $this->isDemoRelayId($relay_id)
            ? [$this->buildDemoRelaySnapshotEntry($relay_id, $on)]
            : $this->sendShellyPost('/v2/devices/api/set/switch', $payload);
        $this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response);
        return true;
    }

    /**
     * @throws \Exception
     */
    private function seedLaneShellySnapshotFromSwitch(string $relay_id, bool $on, array|object|null $response): void
    {
        $snapshot = $this->getCachedLaneShellyStatusSnapshot() ?? [];
        $existing = $snapshot[$relay_id] ?? [];
        if (!is_array($existing)) {
            $existing = [];
        }

        $seed = [
            'id' => $relay_id,
            'on' => $on,
            'status' => ['switch:0' => ['output' => $on]],
        ];

        $response_entry = $this->extractRelaySnapshotEntryFromSwitchResponse($response, $relay_id);
        if ($response_entry !== null) {
            $seed = array_replace_recursive($seed, $response_entry);
        }

        $snapshot[$relay_id] = array_replace_recursive($existing, $seed);
        $this->setCachedLaneShellyStatusSnapshot($snapshot);
    }

    /**
     * @return array|null
     */
    private function extractRelaySnapshotEntryFromSwitchResponse(array|object|null $response, string $relay_id): ?array
    {
        $devices = $this->normalizeRelayDevicesResponse($response);
        if (count($devices) < 1) {
            return null;
        }

        foreach ($devices as $device) {
            $id = isset($device['id']) ? (string)$device['id'] : '';
            if ($id === $relay_id) {
                return $device;
            }
        }

        return $devices[0];
    }
}
