<?php

app_require('modules/selfserve/traits/selfserve_lane_relay_controller_t.php');
app_require('modules/selfserve/helpers/selfserve_lane_relay.php');
app_require('modules/selfserve/helpers/selfserve_lane_status.php');

use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_status;
use modules\selfserve\traits\selfserve_lane_relay_controller_t;

class SelfserveRelayValueFake
{
    public function __construct(private readonly string $value) {}

    public function value(): string
    {
        return $this->value;
    }
}

class SelfserveDepartmentLaneRelayFake
{
    public SelfserveRelayValueFake $relay_machine_id;
    public SelfserveRelayValueFake $relay_machine_program_picker_id;
    public SelfserveRelayValueFake $relay_machine_cleaner_id;

    public function __construct(
        string $machineRelayId = 'relay-machine',
        string $programPickerRelayId = 'relay-program',
        string $cleanerRelayId = 'relay-cleaner'
    ) {
        $this->relay_machine_id = new SelfserveRelayValueFake($machineRelayId);
        $this->relay_machine_program_picker_id = new SelfserveRelayValueFake($programPickerRelayId);
        $this->relay_machine_cleaner_id = new SelfserveRelayValueFake($cleanerRelayId);
    }
}

class SelfserveShellyRedisClientFake
{
    public function __construct(private readonly SelfserveShellyRedisFake $owner) {}

    public function set(string $key, string $value, mixed ...$args): bool|string
    {
        return $this->owner->clientSet($key, $value, $args);
    }

    public function pttl(string $key): int
    {
        return $this->owner->clientPttl($key);
    }
}

class SelfserveShellyRedisFake
{
    /** @var array<string,string> */
    private array $store = [];
    /** @var array<string,float> */
    private array $expiresAt = [];
    private SelfserveShellyRedisClientFake $client;
    /** @var callable():float */
    private $nowProvider;

    public function __construct(callable $nowProvider)
    {
        $this->nowProvider = $nowProvider;
        $this->client = new SelfserveShellyRedisClientFake($this);
    }

    public function get(string $key): ?string
    {
        $this->purgeExpired($key);
        return $this->store[$key] ?? null;
    }

    public function setEx(string $key, string $value, int $ttl): self
    {
        $this->store[$key] = $value;
        $this->expiresAt[$key] = $this->now() + max(1, $ttl);
        return $this;
    }

    public function get_client(): SelfserveShellyRedisClientFake
    {
        return $this->client;
    }

    public function clientSet(string $key, string $value, array $args): bool|string
    {
        $this->purgeExpired($key);

        $useNx = false;
        $ttlMs = null;
        $count = count($args);
        for ($i = 0; $i < $count; $i++) {
            $token = strtoupper((string)$args[$i]);
            if ($token === 'NX') {
                $useNx = true;
                continue;
            }
            if ($token === 'PX' && isset($args[$i + 1])) {
                $ttlMs = max(1, (int)$args[$i + 1]);
                $i++;
            }
        }

        if ($useNx && array_key_exists($key, $this->store)) {
            return false;
        }

        $this->store[$key] = $value;
        if ($ttlMs === null) {
            unset($this->expiresAt[$key]);
        } else {
            $this->expiresAt[$key] = $this->now() + ($ttlMs / 1000);
        }

        return 'OK';
    }

    public function clientPttl(string $key): int
    {
        $this->purgeExpired($key);
        if (!array_key_exists($key, $this->store)) {
            return -2;
        }
        if (!isset($this->expiresAt[$key])) {
            return -1;
        }

        $remainingMs = (int)ceil(($this->expiresAt[$key] - $this->now()) * 1000);
        return $remainingMs > 0 ? $remainingMs : 0;
    }

    private function now(): float
    {
        $provider = $this->nowProvider;
        return (float)$provider();
    }

    private function purgeExpired(string $key): void
    {
        if (!isset($this->expiresAt[$key])) {
            return;
        }
        if ($this->now() < $this->expiresAt[$key]) {
            return;
        }
        unset($this->expiresAt[$key], $this->store[$key]);
    }
}

class SelfserveLaneRelayControllerHarness
{
    use selfserve_lane_relay_controller_t;

    public const CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES = 'allowed_services';

    public int $id = 1;
    public object $department_lane;
    public bool $selfServeEnabled = true;
    public float $now = 0.0;
    /** @var array<int,int> */
    public array $sleepCalls = [];
    /** @var array<int,array{endpoint:string,payload:array,time:float}> */
    public array $shellyCalls = [];
    /** @var array<string,mixed> */
    public array $laneCache = [];
    /** @var array<string,array<int,array|object|null>> */
    private array $queuedResponses = [];
    private selfserve_lane_status $laneStatus;
    private ?SelfserveShellyRedisFake $redis;

    public function __construct(?SelfserveShellyRedisFake $redis = null)
    {
        $this->department_lane = new SelfserveDepartmentLaneRelayFake();
        $this->laneStatus = selfserve_lane_status::AVAILABLE;
        $this->redis = $redis;
    }

    public function queueShellyResponse(string $endpoint, array|object|null $response): void
    {
        if (!isset($this->queuedResponses[$endpoint])) {
            $this->queuedResponses[$endpoint] = [];
        }
        $this->queuedResponses[$endpoint][] = $response;
    }

    public function getShellyCallCount(string $endpoint): int
    {
        $count = 0;
        foreach ($this->shellyCalls as $call) {
            if ($call['endpoint'] === $endpoint) {
                $count++;
            }
        }
        return $count;
    }

    public function getLaneStatus(): selfserve_lane_status
    {
        return $this->laneStatus;
    }

    public function setLaneStatus(selfserve_lane_status $laneStatus): void
    {
        $this->laneStatus = $laneStatus;
    }

    public function getLaneCache(int $lane_id, string $key): mixed
    {
        return $this->laneCache[$key . '_' . $lane_id] ?? null;
    }

    public function setLaneCache(int $lane_id, string $key, mixed $value): self
    {
        $this->laneCache[$key . '_' . $lane_id] = $value;
        return $this;
    }

    protected function sendShellyPost(string $endpoint, array $payload): array|object|null
    {
        $this->shellyCalls[] = [
            'endpoint' => $endpoint,
            'payload' => $payload,
            'time' => $this->now,
        ];

        if (!isset($this->queuedResponses[$endpoint]) || count($this->queuedResponses[$endpoint]) < 1) {
            return [];
        }

        return array_shift($this->queuedResponses[$endpoint]);
    }

    protected function redisFacade(): mixed
    {
        return $this->redis;
    }

    protected function nowTimestamp(): float
    {
        return $this->now;
    }

    protected function sleepMicroseconds(int $microseconds): void
    {
        if ($microseconds > 0) {
            $this->sleepCalls[] = $microseconds;
            $this->now += ($microseconds / 1000000);
        }
    }

    protected function isDepartmentSelfServeEnabled(): bool
    {
        return $this->selfServeEnabled;
    }
}

function selfserve_lane_shelly_test_harness(bool $withRedis = true): SelfserveLaneRelayControllerHarness
{
    $harness = null;
    $redis = null;
    if ($withRedis) {
        $redis = new SelfserveShellyRedisFake(static function () use (&$harness): float {
            return $harness?->now ?? 0.0;
        });
    }

    $harness = new SelfserveLaneRelayControllerHarness($redis);
    return $harness;
}

it('batches sequential machine relay status requests into a single Shelly get call', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->queueShellyResponse('/v2/devices/api/get', [
        [
            'id' => 'relay-machine',
            'online' => true,
            'status' => ['switch:0' => ['output' => true]],
        ],
        [
            'id' => 'relay-program',
            'online' => true,
            'status' => ['switch:0' => ['output' => false]],
        ],
        [
            'id' => 'relay-cleaner',
            'online' => true,
            'status' => ['switch:0' => ['output' => true]],
        ],
    ]);

    $machine = $harness->getMachineRelayStatus();
    $programPicker = $harness->getMachineProgramPickerRelayStatus();
    $cleaner = $harness->getMachineCleanerRelayStatus();

    expect($machine['relay_id'])->toBe('relay-machine');
    expect($machine['on'])->toBeTrue();
    expect($programPicker['relay_id'])->toBe('relay-program');
    expect($programPicker['on'])->toBeFalse();
    expect($cleaner['relay_id'])->toBe('relay-cleaner');
    expect($cleaner['on'])->toBeTrue();

    expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1);
    expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-machine', 'relay-program', 'relay-cleaner']);
    expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']);
});

it('keeps back-to-back get/switch requests ordered through the relay controller', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->queueShellyResponse('/v2/devices/api/get', [
        [
            'id' => 'relay-machine',
            'online' => true,
            'status' => ['switch:0' => ['output' => true]],
        ],
    ]);
    $harness->queueShellyResponse('/v2/devices/api/set/switch', [
        [
            'id' => 'relay-machine',
            'online' => true,
            'status' => ['switch:0' => ['output' => false]],
        ],
    ]);

    $harness->getMachineRelayStatus();
    $harness->setMachineRelayStatus(false);

    expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1);
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
    expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/get');
    expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch');
});

it('hard MACHINE relay helper targets the configured MACHINE relay', function (): void {
    $harness = selfserve_lane_shelly_test_harness();

    $result = $harness->setMachineRelayStatusHard(true);

    expect($result)->toBeTrue();
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
    expect($harness->shellyCalls[0]['payload'])->toMatchArray([
        'id' => 'relay-machine',
        'channel' => 0,
        'on' => true,
    ]);
});

it('executes sequential switch requests used by wash start and stop flows', function (): void {
    $harness = selfserve_lane_shelly_test_harness();

    for ($i = 0; $i < 5; $i++) {
        $harness->queueShellyResponse('/v2/devices/api/set/switch', [
            [
                'id' => 'relay-machine',
                'online' => true,
                'status' => ['switch:0' => ['output' => true]],
            ],
        ]);
    }

    // START-like sequence.
    $harness->setMachineCleanerRelayStatusHard(true);
    $harness->setMachineRelayStatusHard(true);
    // STOP-like sequence.
    $harness->setMachineCleanerRelayStatusHard(false);
    $harness->setMachineProgramPickerRelayStatusHard(false);
    $harness->setMachineRelayStatusHard(false);

    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(5);
});

it('retries missing Shelly status payloads until relay status becomes ready', function (): void {
    $harness = selfserve_lane_shelly_test_harness(false);
    $harness->queueShellyResponse('/v2/devices/api/get', [
        ['id' => 'relay-machine', 'online' => true],
    ]);
    $harness->queueShellyResponse('/v2/devices/api/get', [
        [
            'id' => 'relay-machine',
            'online' => true,
            'status' => ['switch:0' => ['output' => true]],
        ],
    ]);

    $status = $harness->getMachineRelayStatus();

    expect($status['relay_id'])->toBe('relay-machine');
    expect($status['on'])->toBeTrue();
    expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(2);
    expect($harness->sleepCalls)->toContain(250000);
});

it('times out with a clear Shelly readiness error when payload remains missing', function (): void {
    $harness = selfserve_lane_shelly_test_harness(false);
    for ($i = 0; $i < 100; $i++) {
        $harness->queueShellyResponse('/v2/devices/api/get', [
            ['id' => 'relay-machine', 'online' => true],
        ]);
    }

    $exception = null;
    try {
        $harness->getMachineRelayStatus();
    } catch (\Exception $e) {
        $exception = $e;
    }

    expect($exception)->toBeInstanceOf(\Exception::class);
    expect($exception?->getMessage())->toContain('not ready');
    expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBeGreaterThan(1);
});

it('uses direct set/switch and seeds cache so immediate status read does not call Shelly get', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->queueShellyResponse('/v2/devices/api/set/switch', [
        [
            'id' => 'relay-program',
            'online' => true,
            'status' => ['switch:0' => ['output' => true]],
        ],
    ]);

    $harness->setMachineProgramPickerRelayStatus(true);
    $status = $harness->getMachineProgramPickerRelayStatus();

    expect($status['relay_id'])->toBe('relay-program');
    expect($status['on'])->toBeTrue();
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
    expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0);
    expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/set/switch');
    expect($harness->shellyCalls[0]['payload'])->toMatchArray([
        'id' => 'relay-program',
        'channel' => 0,
        'on' => true,
    ]);
    expect(array_key_exists('toggle_after', $harness->shellyCalls[0]['payload']))->toBeFalse();
});

it('preserves local gateway diagnostic metadata on relay status snapshots', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->queueShellyResponse('/v2/devices/api/get', [
        [
            'id' => 'relay-machine',
            'relay_id' => 'relay-machine',
            'online' => true,
            'on' => true,
            'status' => ['switch:0' => ['output' => true]],
            'binding' => [
                'logical_relay_id' => 'relay-machine',
                'device_id' => 'device-machine',
                'local_ip' => '192.168.1.50',
                'channel' => 0,
            ],
            'execution' => [
                'transport' => 'gateway',
                'fallback_mode' => 'PREFER_LOCAL',
            ],
            'raw' => [
                'source' => 'edge-gateway',
            ],
        ],
    ]);

    $status = $harness->getMachineRelayStatus();

    expect($status)->toMatchArray([
        'relay_id' => 'relay-machine',
        'online' => true,
        'on' => true,
        'status' => ['switch:0' => ['output' => true]],
        'binding' => [
            'logical_relay_id' => 'relay-machine',
            'device_id' => 'device-machine',
            'local_ip' => '192.168.1.50',
            'channel' => 0,
        ],
        'execution' => [
            'transport' => 'gateway',
            'fallback_mode' => 'PREFER_LOCAL',
        ],
        'raw' => [
            'source' => 'edge-gateway',
        ],
    ]);
});

it('includes positive relay timers in Shelly switch payloads', function (): void {
    $harness = selfserve_lane_shelly_test_harness();

    $harness->forceTurnOnMachineRelay(9);

    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
    expect($harness->shellyCalls[0]['payload'])->toMatchArray([
        'id' => 'relay-machine',
        'channel' => 0,
        'on' => true,
        'toggle_after' => 9,
    ]);
});

it('supports hard relay set even when lane status is CLOSED', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->setLaneStatus(selfserve_lane_status::CLOSED);
    $harness->queueShellyResponse('/v2/devices/api/set/switch', [
        [
            'id' => 'relay-machine',
            'online' => true,
            'status' => ['switch:0' => ['output' => false]],
        ],
    ]);

    $result = $harness->setMachineRelayStatusHard(false);

    expect($result)->toBeTrue();
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
});

it('blocks explicit relay writes when department self-serve is disabled', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->selfServeEnabled = false;

    expect(fn() => $harness->setMachineRelayStatusHard(false))
        ->toThrow(\Exception::class, 'Self-serve is not enabled');
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});

it('enables MACHINE relay when MACHINE is visible in allowed services', function (): void {
    $harness = selfserve_lane_shelly_test_harness();

    $result = $harness->syncMachineRelayFromVisibleServices(['machine'], true);

    expect($result)->toMatchArray([
        'machine_visible' => true,
        'relay_action' => 'enabled',
        'relay_target_on' => true,
    ]);
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
    expect($harness->shellyCalls[0]['payload'])->toMatchArray([
        'id' => 'relay-machine',
        'channel' => 0,
        'on' => true,
    ]);
});

it('turns MACHINE relay off immediately when MACHINE is not visible', function (): void {
    $harness = selfserve_lane_shelly_test_harness();

    $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE_CLEANER'], true);

    expect($result)->toMatchArray([
        'machine_visible' => false,
        'relay_action' => 'disabled',
        'relay_target_on' => false,
    ]);
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
    expect($harness->shellyCalls[0]['payload'])->toMatchArray([
        'id' => 'relay-machine',
        'channel' => 0,
        'on' => false,
    ]);
});

it('does not enable MACHINE relay when allowEnable is false even if MACHINE is visible', function (): void {
    $harness = selfserve_lane_shelly_test_harness();

    $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], false);

    expect($result)->toMatchArray([
        'machine_visible' => true,
        'relay_action' => 'noop_enable_blocked',
        'relay_target_on' => true,
    ]);
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});

it('persists allowed services without relay writes for pre-start wash setup', function (): void {
    $harness = selfserve_lane_shelly_test_harness();

    $result = $harness->setAllowedServicesFromVisibleTasks(['machine']);

    expect($result)->toMatchArray([
        'machine_visible' => true,
        'relay_action' => 'cache_only',
        'relay_target_on' => true,
    ]);
    expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']);
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});

it('returns disabled no-op from machine relay visibility sync when department self-serve is disabled', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->selfServeEnabled = false;

    $result = $harness->syncMachineRelayFromVisibleServices(['machine'], true);

    expect($result)->toMatchArray([
        'machine_visible' => true,
        'relay_action' => 'noop_selfserve_disabled',
        'relay_target_on' => true,
    ]);
    expect($harness->getLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES))->toBe(['MACHINE']);
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});

it('is a safe no-op when MACHINE relay is not configured', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->department_lane = new SelfserveDepartmentLaneRelayFake(
        machineRelayId: '',
        programPickerRelayId: 'relay-program',
        cleanerRelayId: 'relay-cleaner'
    );

    $result = $harness->syncMachineRelayFromVisibleServices(['MACHINE'], true);

    expect($result)->toMatchArray([
        'machine_visible' => true,
        'relay_action' => 'noop_missing_machine_relay',
        'relay_target_on' => true,
    ]);
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});

it('blocks setMachineRelayStatus from enabling MACHINE when MACHINE is not allowed', function (): void {
    $harness = selfserve_lane_shelly_test_harness();

    expect(fn() => $harness->setMachineRelayStatus(true))
        ->toThrow(\Exception::class, 'MACHINE relay is not allowed to be enabled at this time');
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
});

it('uses local demo responses and skips Shelly cloud calls for demo relay ids', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->department_lane = new SelfserveDepartmentLaneRelayFake(
        machineRelayId: 'demo-machine',
        programPickerRelayId: 'demo-program',
        cleanerRelayId: 'demo-cleaner'
    );
    $harness->setLaneCache($harness->id, $harness::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES, ['MACHINE']);

    $result = $harness->setMachineRelayStatus(true);
    $status = $harness->getMachineRelayStatus();

    expect($result)->toBeTrue();
    expect($status)->toMatchArray([
        'relay_id' => 'demo-machine',
        'online' => true,
        'on' => true,
    ]);
    expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(0);
    expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0);
});

it('returns default OFF status for demo relay ids without Shelly lookups', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->department_lane = new SelfserveDepartmentLaneRelayFake(
        machineRelayId: 'demo-machine',
        programPickerRelayId: 'demo-program',
        cleanerRelayId: 'demo-cleaner'
    );

    $status = $harness->getMachineRelayStatus();

    expect($status)->toMatchArray([
        'relay_id' => 'demo-machine',
        'online' => true,
        'on' => false,
    ]);
    expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(0);
});

it('excludes demo relay ids from Shelly status batch payloads', function (): void {
    $harness = selfserve_lane_shelly_test_harness();
    $harness->department_lane = new SelfserveDepartmentLaneRelayFake(
        machineRelayId: 'demo-machine',
        programPickerRelayId: 'relay-program',
        cleanerRelayId: 'demo-cleaner'
    );
    $harness->queueShellyResponse('/v2/devices/api/get', [
        [
            'id' => 'relay-program',
            'online' => true,
            'status' => ['switch:0' => ['output' => true]],
        ],
    ]);

    $status = $harness->getMachineProgramPickerRelayStatus();

    expect($status['relay_id'])->toBe('relay-program');
    expect($status['on'])->toBeTrue();
    expect($harness->getShellyCallCount('/v2/devices/api/get'))->toBe(1);
    expect($harness->shellyCalls[0]['payload']['ids'])->toBe(['relay-program']);
});
