// biome-ignore assist/source/organizeImports: import mocks first
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from "vitest";
import * as data from "../mocks/data";
import {mockLogger} from "../mocks/logger";
import {events as mockMQTTEvents, mockMQTTPublishAsync} from "../mocks/mqtt";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents, returnDevices} from "../mocks/zigbeeHerdsman";

import assert from "node:assert";
import {stringify} from "../../lib/util/stringify";
import {Controller} from "../../lib/controller";
import Availability from "../../lib/extension/availability";
import * as settings from "../../lib/util/settings";
import utils from "../../lib/util/utils";

const mocksClear = [mockMQTTPublishAsync, mockLogger.warning, mockLogger.info];

returnDevices.push(
    devices.bulb_color.ieeeAddr,
    devices.bulb_color_2.ieeeAddr,
    devices.coordinator.ieeeAddr,
    devices.remote.ieeeAddr,
    devices.TS0601_thermostat.ieeeAddr,
    devices.bulb_2.ieeeAddr,
    devices.ZNCZ02LM.ieeeAddr,
    devices.GLEDOPTO_2ID.ieeeAddr,
    devices.QBKG03LM.ieeeAddr,
    devices.hue_twilight.ieeeAddr,
);

describe("Extension: Availability", () => {
    let controller: Controller;

    const resetExtension = async (): Promise<void> => {
        await controller.removeExtension(controller.getExtension("Availability")!);
        await controller.addExtension(new Availability(...controller.extensionArgs));
    };

    const setTimeAndAdvanceTimers = async (value: number): Promise<void> => {
        vi.setSystemTime(Date.now() + value);
        await vi.advanceTimersByTimeAsync(value);
    };

    beforeAll(async () => {
        vi.spyOn(utils, "sleep").mockImplementation(vi.fn());
        vi.useFakeTimers();
        settings.reRead();
        settings.set(["availability"], {enabled: true});
        controller = new Controller(vi.fn(), vi.fn());
        await controller.start();
        await flushPromises();
    });

    beforeEach(async () => {
        vi.setSystemTime(utils.minutes(1));
        data.writeDefaultConfiguration();
        settings.reRead();
        settings.set(["availability"], {enabled: true});
        settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], false);
        settings.set(["devices", devices.hue_twilight.ieeeAddr, "availability"], {max_jitter: 1000});
        settings.set(["devices", devices.GLEDOPTO_2ID.ieeeAddr, "availability"], {backoff: false});
        settings.set(["devices", devices.QBKG03LM.ieeeAddr, "availability"], {pause_on_backoff_gt: 1.5});
        for (const key in devices) devices[key as keyof typeof devices].lastSeen = utils.minutes(1);
        for (const mock of mocksClear) mock.mockClear();
        await resetExtension();
        for (const key in devices) devices[key as keyof typeof devices].ping.mockClear();
    });

    afterEach(async () => {});

    afterAll(async () => {
        await controller?.stop();
        await flushPromises();
        vi.useRealTimers();
    });

    it("Should publish availability on startup for device where it is enabled for", () => {
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "online"}), {
            retain: true,
            qos: 1,
        });
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "online"}), {retain: true, qos: 1});
        expect(mockMQTTPublishAsync).not.toHaveBeenCalledWith("zigbee2mqtt/bulb_color_2/availability", stringify({state: "online"}), {
            retain: true,
            qos: 1,
        });
    });

    it("Should ping on startup for enabled and unavailable devices", async () => {
        settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
        devices.bulb_color.lastSeen = Date.now() - utils.minutes(20);
        devices.bulb_color_2.lastSeen = Date.now();
        await resetExtension();

        await setTimeAndAdvanceTimers(utils.minutes(1));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1); // enabled/unavailable
        expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // enabled/available
    });

    it("Should not ping on startup for available or disabled devices", async () => {
        settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
        settings.set(["devices", devices.bulb_color_2.ieeeAddr, "disabled"], true);
        devices.bulb_color.lastSeen = Date.now();
        devices.bulb_color_2.lastSeen = Date.now() - utils.minutes(20);
        await resetExtension();

        await setTimeAndAdvanceTimers(utils.minutes(1));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0); // enabled/available
        expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0); // disabled/unavailable
    });

    it("Should publish offline for active device when not seen for 10 minutes", async () => {
        mockMQTTPublishAsync.mockClear();
        await setTimeAndAdvanceTimers(utils.minutes(5));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);

        await setTimeAndAdvanceTimers(utils.minutes(7));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "offline"}), {
            retain: true,
            qos: 1,
        });
    });

    it("Shouldnt do anything for a device when availability: false is set for device", async () => {
        await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2}); // Coverage satisfaction

        await setTimeAndAdvanceTimers(utils.minutes(12));
        expect(devices.bulb_color_2.ping).toHaveBeenCalledTimes(0);
    });

    it("Should publish offline for passive device when not seen for 25 hours", async () => {
        mockMQTTPublishAsync.mockClear();
        await setTimeAndAdvanceTimers(utils.hours(26));
        expect(devices.remote.ping).toHaveBeenCalledTimes(0);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "offline"}), {retain: true, qos: 1});
    });

    it("Should reset ping timer when device last seen changes for active device", async () => {
        mockMQTTPublishAsync.mockClear();

        await setTimeAndAdvanceTimers(utils.minutes(6));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "offline"}), {
            retain: true,
            qos: 1,
        });

        await setTimeAndAdvanceTimers(utils.minutes(7));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);

        await setTimeAndAdvanceTimers(utils.minutes(10));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
    });

    it("Should ping again when first ping fails", async () => {
        mockMQTTPublishAsync.mockClear();

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});

        devices.bulb_color.ping.mockImplementationOnce(() => {
            throw new Error("failed");
        });

        await setTimeAndAdvanceTimers(utils.minutes(15));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
    });

    it("Should reset ping timer when device last seen changes for passive device", async () => {
        mockMQTTPublishAsync.mockClear();

        await setTimeAndAdvanceTimers(utils.hours(24));
        expect(devices.remote.ping).toHaveBeenCalledTimes(0);

        await mockZHEvents.lastSeenChanged({device: devices.remote});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "offline"}), {retain: true, qos: 1});

        await setTimeAndAdvanceTimers(utils.hours(25));
        expect(devices.remote.ping).toHaveBeenCalledTimes(0);

        await setTimeAndAdvanceTimers(utils.hours(3));
        expect(devices.remote.ping).toHaveBeenCalledTimes(0);
    });

    it("Should immediately mark device as online when it lastSeen changes", async () => {
        mockMQTTPublishAsync.mockClear();

        await setTimeAndAdvanceTimers(utils.minutes(15));
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "offline"}), {
            retain: true,
            qos: 1,
        });

        devices.bulb_color.lastSeen = Date.now();
        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", stringify({state: "online"}), {
            retain: true,
            qos: 1,
        });
    });

    it("Should allow to change availability timeout via device options", async () => {
        settings.set(["devices", "0x000b57fffec6a5b3", "availability"], {timeout: 40});
        await resetExtension();
        devices.bulb_color.ping.mockClear();

        await setTimeAndAdvanceTimers(utils.minutes(25));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);

        await setTimeAndAdvanceTimers(utils.minutes(17));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
    });

    it("Should not ping disabled devices", async () => {
        settings.set(["devices", devices.bulb_color.ieeeAddr, "disabled"], true);
        await resetExtension();
        devices.bulb_color.ping.mockClear();

        await setTimeAndAdvanceTimers(utils.minutes(15));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
    });

    it("Should allow to change availability timeout via avaiability options", async () => {
        settings.set(["availability", "active", "timeout"], 30);
        await resetExtension();
        devices.bulb_color.ping.mockClear();

        await setTimeAndAdvanceTimers(utils.minutes(25));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);

        await setTimeAndAdvanceTimers(utils.minutes(7));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
    });

    it("Should stop pinging device when it leaves", async () => {
        await setTimeAndAdvanceTimers(utils.minutes(9));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);

        await mockZHEvents.deviceLeave({ieeeAddr: devices.bulb_color.ieeeAddr});
        await flushPromises();

        await setTimeAndAdvanceTimers(utils.minutes(3));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
    });

    it("Should stop pinging device when it is removed", async () => {
        await setTimeAndAdvanceTimers(utils.minutes(9));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/remove", stringify({id: "bulb_color"}));
        await flushPromises();

        await setTimeAndAdvanceTimers(utils.minutes(3));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
    });

    it("Should allow to be disabled", async () => {
        settings.set(["availability"], {enabled: false});
        await resetExtension();
        devices.bulb_color.ping.mockClear();

        await setTimeAndAdvanceTimers(utils.minutes(12));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(0);
    });

    it("Should allow to enable availability for just one device", async () => {
        settings.set(["availability"], {enabled: false});
        settings.set(["devices", devices.bulb_color.ieeeAddr, "availability"], true);

        await resetExtension();
        devices.bulb_color.ping.mockClear();

        await setTimeAndAdvanceTimers(utils.minutes(11));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
    });

    it("Should retrieve device state when it reconnects", async () => {
        const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
        controller.state.set(device, {state: "OFF"});

        const endpoint = devices.bulb_color.getEndpoint(1);
        assert(endpoint);
        endpoint.read.mockClear();

        await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
        await flushPromises();
        await setTimeAndAdvanceTimers(utils.seconds(1));
        await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
        await flushPromises();

        expect(endpoint.read).toHaveBeenCalledTimes(0);
        await setTimeAndAdvanceTimers(utils.seconds(2));

        expect(endpoint.read).toHaveBeenCalledTimes(1);
        expect(endpoint.read).toHaveBeenCalledWith("genOnOff", ["onOff"]);

        endpoint.read.mockClear();
        await mockZHEvents.deviceAnnounce({device: devices.bulb_color});
        await flushPromises();
        endpoint.read.mockImplementationOnce(() => {
            throw new Error("");
        });
        await setTimeAndAdvanceTimers(utils.seconds(3));
        expect(endpoint.read).toHaveBeenCalledTimes(1);
    });

    it("Should republish availability when device is renamed", async () => {
        mockMQTTPublishAsync.mockClear();

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/rename", stringify({from: "bulb_color", to: "bulb_new_name"}));
        await flushPromises();

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_color/availability", "", {retain: true, qos: 1});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_new_name/availability", stringify({state: "online"}), {
            retain: true,
            qos: 1,
        });
        await setTimeAndAdvanceTimers(utils.hours(12));
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb_new_name/availability", stringify({state: "offline"}), {
            retain: true,
            qos: 1,
        });
    });

    it("Should publish availability payload in JSON format", async () => {
        await resetExtension();
        devices.remote.ping.mockClear();
        mockMQTTPublishAsync.mockClear();
        await setTimeAndAdvanceTimers(utils.hours(26));
        expect(devices.remote.ping).toHaveBeenCalledTimes(0);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/remote/availability", stringify({state: "offline"}), {retain: true, qos: 1});
    });

    it("Should publish availability for groups", async () => {
        settings.set(["devices", devices.bulb_color_2.ieeeAddr, "availability"], true);
        await resetExtension();
        devices.bulb_color_2.ping.mockClear();

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "online"}), {
            retain: true,
            qos: 1,
        });
        mockMQTTPublishAsync.mockClear();
        await setTimeAndAdvanceTimers(utils.minutes(12));
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "offline"}), {
            retain: true,
            qos: 1,
        });
        mockMQTTPublishAsync.mockClear();
        devices.bulb_color_2.lastSeen = Date.now();
        await mockZHEvents.lastSeenChanged({device: devices.bulb_color_2});
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/group_tradfri_remote/availability", stringify({state: "online"}), {
            retain: true,
            qos: 1,
        });
    });

    it("Should clear the ping queue on stop", async () => {
        const availability = controller.getExtension("Availability")! as Availability;
        // @ts-expect-error private
        const publishAvailabilitySpy = vi.spyOn(availability, "publishAvailability");

        devices.bulb_color.ping.mockImplementationOnce(() => new Promise((resolve) => setTimeout(resolve, 1000)));
        // @ts-expect-error private
        availability.addToPingQueue(devices.bulb_color);
        // @ts-expect-error private
        availability.addToPingQueue(devices.bulb_color_2);

        await availability.stop();
        await setTimeAndAdvanceTimers(utils.minutes(1));

        // @ts-expect-error private
        expect(availability.pingQueue).toEqual([]);
        // Validate the stop-interrupt implicitly by checking that it prevents further function invocations
        expect(publishAvailabilitySpy).not.toHaveBeenCalled();
        devices.bulb_color.ping = vi.fn(); // ensure reset
    });

    it("Should prevent instance restart", async () => {
        const availability = controller.getExtension("Availability")! as Availability;

        await availability.stop();

        await expect(() => availability.start()).rejects.toThrow();
    });

    it("jitters pings", async () => {
        const devicesPings = [
            devices.bulb_color.ping,
            devices.TS0601_thermostat.ping,
            devices.bulb_2.ping,
            devices.ZNCZ02LM.ping,
            devices.GLEDOPTO_2ID.ping,
            devices.QBKG03LM.ping,
            devices.hue_twilight.ping,
        ];
        let called = 0;
        let lastCalled = 0;

        await setTimeAndAdvanceTimers(utils.minutes(10));

        for (const p of devicesPings) {
            called += p.mock.calls.length;
        }

        expect(called).toStrictEqual(0);

        lastCalled = called;
        called = 0;

        await setTimeAndAdvanceTimers(utils.seconds(2)); // 10:02

        expect(devices.hue_twilight.ping).toHaveBeenCalledTimes(1); // max_jitter: 1000

        await setTimeAndAdvanceTimers(utils.seconds(8)); // 10:10

        for (const p of devicesPings) {
            called += p.mock.calls.length;
        }

        expect(called).toBeGreaterThanOrEqual(0);
        expect(called).toBeLessThan(7);
        expect(called).toBeGreaterThanOrEqual(lastCalled);

        lastCalled = called;
        called = 0;

        await setTimeAndAdvanceTimers(utils.seconds(20)); // 10:20

        for (const p of devicesPings) {
            called += p.mock.calls.length;
        }

        expect(called).toBeGreaterThanOrEqual(lastCalled);

        lastCalled = called;
        called = 0;
        await setTimeAndAdvanceTimers(utils.seconds(35)); // 10:35

        for (const p of devicesPings) {
            called += p.mock.calls.length;
        }

        expect(called).toStrictEqual(7);

        for (const p of devicesPings) {
            expect(p).toHaveBeenCalledTimes(1);
        }
    });

    it("does not trigger backoff on ping success", async () => {
        settings.set(["availability", "active", "max_jitter"], 0); // easier testing

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});

        await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(1);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);

        await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 21:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
    });

    it("triggers backoff on successive ping failures", async () => {
        settings.set(["availability", "active", "max_jitter"], 0); // easier testing

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
        await mockZHEvents.lastSeenChanged({device: devices.GLEDOPTO_2ID}); // backoff: false

        devices.bulb_color.ping
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            });
        devices.GLEDOPTO_2ID.ping
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            });

        await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(2);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenNthCalledWith(1, true);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenNthCalledWith(2, false);

        await setTimeAndAdvanceTimers(utils.minutes(14.5)); // 25:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(3);

        await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 25:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(3);

        await setTimeAndAdvanceTimers(utils.minutes(29.5)); // 55:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(6);

        await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 55:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(6);

        await setTimeAndAdvanceTimers(utils.minutes(59.5)); // 115:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(12);

        await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 115:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(12);

        // backoff was reset (4 mocks done)

        await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 126:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(6);
        expect(devices.GLEDOPTO_2ID.ping).toHaveBeenCalledTimes(13);
    });

    it("resets backoff on last seen", async () => {
        settings.set(["availability", "active", "max_jitter"], 0); // easier testing

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});

        devices.bulb_color.ping
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            });

        await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});

        await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 21:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
    });

    it("pauses when backoff reaches configured value and unpauses on last seen", async () => {
        settings.set(["availability", "active", "max_jitter"], 0); // easier testing
        settings.set(["availability", "active", "pause_on_backoff_gt"], 3);

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
        await mockZHEvents.lastSeenChanged({device: devices.QBKG03LM}); // pause_on_backoff_gt: 1.5

        devices.bulb_color.ping
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            });
        devices.QBKG03LM.ping
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            });

        await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);
        expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(2);
        expect(devices.QBKG03LM.ping).toHaveBeenNthCalledWith(1, true);
        expect(devices.QBKG03LM.ping).toHaveBeenNthCalledWith(2, false);

        await setTimeAndAdvanceTimers(utils.minutes(14.5)); // 25:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
        expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(2);

        await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 25:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
        expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);

        await setTimeAndAdvanceTimers(utils.minutes(29.5)); // 55:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);
        expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);

        await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 55:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
        expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);

        await setTimeAndAdvanceTimers(utils.minutes(100));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
        expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);

        await setTimeAndAdvanceTimers(utils.minutes(100));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);
        expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(3);

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});
        await mockZHEvents.lastSeenChanged({device: devices.QBKG03LM});

        await setTimeAndAdvanceTimers(utils.minutes(10.5));
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
        expect(devices.QBKG03LM.ping).toHaveBeenCalledTimes(4);
    });

    it("allows to disable backoff", async () => {
        settings.set(["availability", "active", "max_jitter"], 0); // easier testing
        settings.set(["availability", "active", "backoff"], false);

        await mockZHEvents.lastSeenChanged({device: devices.bulb_color});

        devices.bulb_color.ping
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            })
            .mockImplementationOnce(() => {
                throw new Error("failed");
            });

        await setTimeAndAdvanceTimers(utils.minutes(10.5)); // 10:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(1, true);
        expect(devices.bulb_color.ping).toHaveBeenNthCalledWith(2, false);

        await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 20:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(2);

        await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 20:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);

        await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 30:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(3);

        await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 30:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);

        await setTimeAndAdvanceTimers(utils.minutes(9.5)); // 40:00
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(4);

        await setTimeAndAdvanceTimers(utils.minutes(0.5)); // 40:30
        expect(devices.bulb_color.ping).toHaveBeenCalledTimes(5);
    });
});
