// 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 * as mockSleep from "../mocks/sleep";
import {flushPromises} from "../mocks/utils";
import {devices, events as mockZHEvents} from "../mocks/zigbeeHerdsman";

import {join} from "node:path";
import {existsSync, readFileSync, rmSync} from "node:fs";
import {stringify} from "../../lib/util/stringify";
import {Controller} from "../../lib/controller";
import OTAUpdate from "../../lib/extension/otaUpdate";
import * as settings from "../../lib/util/settings";
import type {OtaDataSettings, ZigbeeOtaImageMeta} from "zigbee-herdsman/dist/controller/tstype";
import * as zh from "zigbee-herdsman";
import type {TClusterCommandPayload} from "zigbee-herdsman/dist/zspec/zcl/definition/clusters-types";

const DEFAULT_CONFIG: OtaDataSettings = {
    requestTimeout: 150000,
    responseDelay: 250,
    baseSize: 50,
};

const DEFAULT_CURRENT: TClusterCommandPayload<"genOta", "queryNextImageRequest"> = {
    fieldControl: 0,
    fileVersion: 1,
    manufacturerCode: 2,
    imageType: 3,
};

const DEFAULT_AVAILABLE_META: ZigbeeOtaImageMeta = {
    fileName: "my.ota",
    fileVersion: 1,
    manufacturerCode: 2,
    imageType: 3,
    url: "https://example.com/my.ota",
};

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

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

    beforeAll(async () => {
        vi.useFakeTimers();
        mockSleep.mock();
        data.writeDefaultConfiguration();
        settings.reRead();
        controller = new Controller(vi.fn(), vi.fn());
        await controller.start();
        await flushPromises();
    });

    afterAll(async () => {
        mockSleep.restore();
        await controller?.stop();
        await flushPromises();
        vi.useRealTimers();
        rmSync(data.mockDir, {force: true, recursive: true});
    });

    beforeEach(async () => {
        zh.setOtaConfiguration(data.mockDir, undefined);
        const extension = controller.getExtension("OTAUpdate")! as OTAUpdate;
        extension.clearState();

        await vi.advanceTimersByTimeAsync(10000); // go past the init routines

        mockMQTTPublishAsync.mockClear();
        mockLogger.info.mockClear();
        mockLogger.error.mockClear();
        devices.HGZB04D.mockClear();
        devices.SV01.mockClear();
        devices.bulb.mockClear();
        devices.bulb_color.mockClear();
        controller.state.clear();
    });

    afterEach(() => {
        settings.set(["ota", "disable_automatic_update_check"], false);
    });

    it.each(["update", "update/downgrade"])("updates a device with topic %s", async (type) => {
        const downgrade = type === "update/downgrade";
        let count = 10;
        devices.bulb.endpoints[0].read.mockImplementation(() => {
            if (downgrade) {
                count--;
            } else {
                count++;
            }

            return {swBuildId: count, dateCode: `201901${count}`};
        });
        devices.bulb.updateOta.mockImplementationOnce(
            async (source, _requestPayload, _requestTsn, _extraMetas, onProgress, _dataSettings, _endpoint) => {
                expect(source?.downgrade).toStrictEqual(downgrade);

                onProgress(0, 36000.5678);
                onProgress(10, 3600.2123);
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 90,
                    },
                ]);
            },
        );

        mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, stringify({id: "bulb"}));
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        const fromSwBuildId = 10 + (downgrade ? -1 : +1);
        const toSwBuildId = 10 + (downgrade ? -2 : +2); // 2x from `#readSoftwareBuildIDAndDateCode`
        const fromDateCode = `201901${fromSwBuildId}`;
        const toDateCode = `201901${toSwBuildId}`;
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.updateOta).toHaveBeenCalledWith(
            {downgrade},
            undefined,
            undefined,
            {},
            expect.any(Function),
            {...DEFAULT_CONFIG},
            undefined,
        );

        const infoCalls = mockLogger.info.mock.calls.map((c) => (typeof c[0] === "string" ? c[0] : c[0]()));
        expect(infoCalls[0]).toStrictEqual(`OTA updating 'bulb' to ${downgrade ? "previous" : "latest"} firmware`);
        expect(infoCalls[3]).toStrictEqual(`Finished update of 'bulb'`);
        expect(infoCalls[5]).toStrictEqual(`Device 'bulb' was OTA updated from '1' to '90'`);
        expect(infoCalls[7]).toStrictEqual(`Interviewing 'bulb'`);
        expect(infoCalls[8]).toStrictEqual(`Configuring 'bulb'`);
        expect(infoCalls[10]).toStrictEqual(`Successfully interviewed 'bulb'`);
        expect(infoCalls[11]).toStrictEqual(`Successfully configured 'bulb' (definition v0.0.0)`);

        expect(devices.bulb.save).toHaveBeenCalledTimes(1);
        expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith("genBasic", ["dateCode", "swBuildId"], {sendPolicy: "immediate"});
        expect(devices.bulb.endpoints[0].read).toHaveBeenCalledWith("genBasic", ["dateCode", "swBuildId"], {sendPolicy: undefined});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "updating", progress: 0, remaining: 36001}}),
            {
                retain: true,
                qos: 0,
            },
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 90, latest_version: 90, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/update",
            stringify({
                data: {
                    from: {date_code: fromDateCode, software_build_id: fromSwBuildId, file_version: 1},
                    id: "bulb",
                    to: {date_code: toDateCode, software_build_id: toSwBuildId, file_version: 90},
                },
                status: "ok",
            }),
            {},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/definitions", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
    });

    it("re-interviews and re-configures after successful update", async () => {
        devices.bulb.meta.configured = 123;

        devices.bulb.updateOta.mockImplementationOnce(
            async (_source, _requestPayload, _requestTsn, _extraMetas, _onProgress, _dataSettings, _endpoint) => {
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 2,
                    },
                ]);
            },
        );

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.interview).toHaveBeenCalledTimes(1);
        expect(devices.bulb.save).toHaveBeenCalledTimes(2);

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 2, latest_version: 2, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/definitions", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});

        delete devices.bulb.meta.configured;
    });

    it("handles re-interview failure post-update", async () => {
        devices.bulb.meta.configured = 123;

        devices.bulb.interview.mockRejectedValueOnce(new Error("dragons"));
        devices.bulb.updateOta.mockImplementationOnce(
            async (_source, _requestPayload, _requestTsn, _extraMetas, _onProgress, _dataSettings, _endpoint) => {
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 2,
                    },
                ]);
            },
        );

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.interview).toHaveBeenCalledTimes(1);
        expect(devices.bulb.save).toHaveBeenCalledTimes(2);

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 2, latest_version: 2, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockLogger.error).toHaveBeenCalledWith(
            `Interview of 'bulb' (${devices.bulb.ieeeAddr}) failed: Error: dragons. Re-try manually after some time.`,
        );
    });

    it("updates with url payload", async () => {
        devices.bulb.updateOta.mockImplementationOnce(
            async (_source, _requestPayload, _requestTsn, _extraMetas, _onProgress, _dataSettings, _endpoint) => {
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 2,
                    },
                ]);
            },
        );

        mockMQTTEvents.message(
            "zigbee2mqtt/bridge/request/device/ota_update/update",
            stringify({id: "bulb", url: "https://example.com/myremote.ota"}),
        );
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.updateOta).toHaveBeenNthCalledWith(
            1,
            {url: "https://example.com/myremote.ota", downgrade: false},
            undefined,
            undefined,
            {},
            expect.any(Function),
            {baseSize: 50, requestTimeout: 150000, responseDelay: 250},
            undefined,
        );
        expect(devices.bulb.interview).toHaveBeenCalledTimes(1);

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 2, latest_version: 2, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/definitions", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
    });

    it("updates with hex payload", async () => {
        const now = 123401234;
        vi.setSystemTime(now);
        const genFileName = `${devices.bulb.ieeeAddr}_${now}`;
        const saveFilePath = join(data.mockDir, "ota", genFileName);

        devices.bulb.updateOta.mockImplementationOnce(
            async (_source, _requestPayload, _requestTsn, _extraMetas, _onProgress, _dataSettings, _endpoint) => {
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 2,
                    },
                ]);
            },
        );

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb", hex: {data: "010203"}}));
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.updateOta).toHaveBeenNthCalledWith(
            1,
            {url: saveFilePath, downgrade: false},
            undefined,
            undefined,
            {},
            expect.any(Function),
            {baseSize: 50, requestTimeout: 150000, responseDelay: 250},
            undefined,
        );
        expect(devices.bulb.interview).toHaveBeenCalledTimes(1);

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 2, latest_version: 2, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/definitions", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
        expect(existsSync(saveFilePath)).toStrictEqual(true);
        expect(readFileSync(saveFilePath)).toStrictEqual(Buffer.from([0x01, 0x02, 0x03]));
    });

    it("updates with hex and file name payload", async () => {
        const saveFilePath = join(data.mockDir, "ota", "my.ota");

        devices.bulb.updateOta.mockImplementationOnce(
            async (_source, _requestPayload, _requestTsn, _extraMetas, _onProgress, _dataSettings, _endpoint) => {
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 2,
                    },
                ]);
            },
        );

        mockMQTTEvents.message(
            "zigbee2mqtt/bridge/request/device/ota_update/update",
            stringify({id: "bulb", hex: {data: "010203", file_name: "my.ota"}}),
        );
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.updateOta).toHaveBeenNthCalledWith(
            1,
            {url: saveFilePath, downgrade: false},
            undefined,
            undefined,
            {},
            expect.any(Function),
            {baseSize: 50, requestTimeout: 150000, responseDelay: 250},
            undefined,
        );
        expect(devices.bulb.interview).toHaveBeenCalledTimes(1);

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 2, latest_version: 2, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/definitions", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
        expect(existsSync(saveFilePath)).toStrictEqual(true);
        expect(readFileSync(saveFilePath)).toStrictEqual(Buffer.from([0x01, 0x02, 0x03]));
    });

    it("updates with override data settings", async () => {
        devices.bulb.updateOta.mockImplementationOnce(
            async (_source, _requestPayload, _requestTsn, _extraMetas, _onProgress, _dataSettings, _endpoint) => {
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        fileVersion: 2,
                    },
                ]);
            },
        );

        mockMQTTEvents.message(
            "zigbee2mqtt/bridge/request/device/ota_update/update",
            stringify({id: "bulb", image_block_request_timeout: 200000, image_block_response_delay: 50, default_maximum_data_size: 64}),
        );
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.updateOta).toHaveBeenNthCalledWith(
            1,
            {downgrade: false},
            undefined,
            undefined,
            {},
            expect.any(Function),
            {baseSize: 64, requestTimeout: 200000, responseDelay: 50},
            undefined,
        );
        expect(devices.bulb.interview).toHaveBeenCalledTimes(1);

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 2, latest_version: 2, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/info", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/definitions", expect.any(String), {retain: true});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
    });

    it("schedules with hex payload", async () => {
        const now = 123401234;
        vi.setSystemTime(now);
        const genFileName = `${devices.bulb.ieeeAddr}_${now}`;
        const saveFilePath = join(data.mockDir, "ota", genFileName);

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule", stringify({id: "bulb", hex: {data: "010203"}}));
        await flushPromises();

        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.scheduleOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.scheduleOta).toHaveBeenNthCalledWith(1, {url: saveFilePath, downgrade: false});

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            2,
            "zigbee2mqtt/bridge/response/device/ota_update/schedule",
            stringify({data: {id: "bulb", url: saveFilePath}, status: "ok"}),
            {},
        );
        expect(devices.bulb.scheduledOta).toStrictEqual({url: saveFilePath, downgrade: false});
        expect(existsSync(saveFilePath)).toStrictEqual(true);
        expect(readFileSync(saveFilePath)).toStrictEqual(Buffer.from([0x01, 0x02, 0x03]));

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

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            4,
            "zigbee2mqtt/bridge/response/device/ota_update/unschedule",
            stringify({data: {id: "bulb"}, status: "ok"}),
            {},
        );
        expect(devices.bulb.scheduledOta).toStrictEqual(undefined);
        expect(existsSync(saveFilePath)).toStrictEqual(false);
    });

    it("schedules with hex and file name payload", async () => {
        const saveFilePath = join(data.mockDir, "ota", "my.ota");

        mockMQTTEvents.message(
            "zigbee2mqtt/bridge/request/device/ota_update/schedule",
            stringify({id: "bulb", hex: {data: "010203", file_name: "my.ota"}}),
        );
        await flushPromises();

        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(0);
        expect(devices.bulb.scheduleOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.scheduleOta).toHaveBeenNthCalledWith(1, {url: saveFilePath, downgrade: false});

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            2,
            "zigbee2mqtt/bridge/response/device/ota_update/schedule",
            stringify({data: {id: "bulb", url: saveFilePath}, status: "ok"}),
            {},
        );
        expect(devices.bulb.scheduledOta).toStrictEqual({url: saveFilePath, downgrade: false});
        expect(existsSync(saveFilePath)).toStrictEqual(true);
        expect(readFileSync(saveFilePath)).toStrictEqual(Buffer.from([0x01, 0x02, 0x03]));

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

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            4,
            "zigbee2mqtt/bridge/response/device/ota_update/unschedule",
            stringify({data: {id: "bulb"}, status: "ok"}),
            {},
        );
        expect(devices.bulb.scheduledOta).toStrictEqual(undefined);
        expect(existsSync(saveFilePath)).toStrictEqual(false);
    });

    it("aborts running OTA", async () => {
        let timer: NodeJS.Timeout | undefined;
        devices.bulb.updateOta.mockImplementationOnce(
            async (_source, _requestPayload, _requestTsn, _extraMetas, onProgress, _dataSettings, _endpoint) => {
                onProgress(0, 36000.5678);
                onProgress(10, 3600.2123);

                return await new Promise((resolve) => {
                    timer = setTimeout(
                        () =>
                            resolve([
                                {
                                    ...DEFAULT_CURRENT,
                                    fileVersion: 1,
                                },
                                {
                                    ...DEFAULT_CURRENT,
                                    fileVersion: 90,
                                },
                            ]),
                        10000,
                    );
                });
            },
        );
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
        await flushPromises();
        await vi.advanceTimersByTimeAsync(9000);
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update/abort", stringify({id: "bulb"}));
        await flushPromises();

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "updating", progress: 0, remaining: 36001}}),
            {
                retain: true,
                qos: 0,
            },
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "available"}}), {retain: true, qos: 0});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/update/abort",
            stringify({data: {id: "bulb"}, status: "ok"}),
            {},
        );

        clearTimeout(timer);
    });

    it("handles abort when no running OTA", async () => {
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update/abort", stringify({id: "bulb"}));
        await flushPromises();

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/update",
            stringify({data: {}, status: "error", error: `No OTA in progress to abort for device 'bulb'`}),
            {},
        );
    });

    it("handles when OTA update fails", async () => {
        devices.bulb.endpoints[0].read.mockImplementation(() => {
            return {swBuildId: 1, dateCode: "2019010"};
        });
        devices.bulb.save.mockClear();
        devices.bulb.updateOta.mockRejectedValueOnce(new Error("Update failed"));

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "available"}}), {retain: true, qos: 0});
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/update",
            stringify({data: {}, status: "error", error: "OTA update of 'bulb' failed (Update failed)"}),
            {},
        );
    });

    it("handles when OTA update returns no image available", async () => {
        devices.bulb.endpoints[0].read.mockImplementation(() => {
            return {swBuildId: 1, dateCode: "2019010"};
        });
        devices.bulb.save.mockClear();
        devices.bulb.updateOta.mockResolvedValueOnce([{fieldControl: 0, fileVersion: 1, manufacturerCode: 1, imageType: 1}, undefined]);

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {installed_version: 1, latest_version: 1, latest_release_notes: null, latest_source: null, state: "idle"}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/update",
            stringify({data: {}, status: "error", error: "Update of 'bulb' failed (No image currently available)"}),
            {},
        );
    });

    it("is able to check if OTA update is available", async () => {
        devices.bulb.checkOta.mockResolvedValueOnce({available: false, current: {...DEFAULT_CURRENT, fileVersion: 10}});
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", stringify({id: "bulb"}));
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.checkOta).toHaveBeenNthCalledWith(1, {downgrade: false}, undefined, {});
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(0);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/check",
            stringify({data: {id: "bulb", update_available: false, downgrade: false}, status: "ok"}),
            {},
        );

        mockMQTTPublishAsync.mockClear();
        devices.bulb.checkOta.mockResolvedValueOnce({
            available: true,
            current: {...DEFAULT_CURRENT, fileVersion: 10},
            availableMeta: {...DEFAULT_AVAILABLE_META, fileVersion: 12},
        });
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", stringify({id: "bulb"}));
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(2);
        expect(devices.bulb.checkOta).toHaveBeenNthCalledWith(2, {downgrade: false}, undefined, {});
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(0);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/check",
            stringify({data: {id: "bulb", update_available: true, downgrade: false, source: "https://example.com/my.ota"}, status: "ok"}),
            {},
        );
        devices.bulb.checkOta.mockResolvedValueOnce({available: false, current: {...DEFAULT_CURRENT, fileVersion: 10}});
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check/downgrade", stringify({id: "bulb"}));
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(3);
        expect(devices.bulb.checkOta).toHaveBeenNthCalledWith(3, {downgrade: true}, undefined, {});
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(0);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/check",
            stringify({data: {id: "bulb", update_available: false, downgrade: true}, status: "ok"}),
            {},
        );

        // @ts-expect-error private
        const device = controller.zigbee.resolveDevice(devices.bulb.ieeeAddr)!;
        const originalDefinition = device.definition;
        device.definition = Object.assign({}, originalDefinition, {ota: {modelId: "dragons"}});

        mockMQTTPublishAsync.mockClear();
        devices.bulb.checkOta.mockResolvedValueOnce({
            available: true,
            current: {...DEFAULT_CURRENT, fileVersion: 10},
            availableMeta: {...DEFAULT_AVAILABLE_META, fileVersion: 8},
        });
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check/downgrade", stringify({id: "bulb"}));
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(4);
        expect(devices.bulb.checkOta).toHaveBeenNthCalledWith(4, {downgrade: true}, undefined, {modelId: "dragons"});
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(0);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/check",
            stringify({data: {id: "bulb", update_available: true, downgrade: true, source: "https://example.com/my.ota"}, status: "ok"}),
            {},
        );

        device.definition = originalDefinition;
    });

    it("handles if OTA update check fails", async () => {
        devices.bulb.checkOta.mockRejectedValueOnce(new Error("RF signals disturbed because of dogs barking"));

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", stringify({id: "bulb"}));
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(0);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/check",
            stringify({
                data: {},
                status: "error",
                error: `Failed to check if OTA update available for 'bulb' (RF signals disturbed because of dogs barking)`,
            }),
            {},
        );
    });

    it("fails when device does not exist", async () => {
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/check", stringify({id: "not_existing_deviceooo"}));
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/check",
            stringify({data: {}, status: "error", error: `Device 'not_existing_deviceooo' does not exist`}),
            {},
        );
    });

    it.each(["check", "update", "schedule"])("does not OTA %s when device does not support it", async (api) => {
        mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${api}`, stringify({id: "dimmer_wall_switch"}));
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            `zigbee2mqtt/bridge/response/device/ota_update/${api}`,
            stringify({data: {}, status: "error", error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
            {},
        );
    });

    it("allows check OTA with custom URL even when device does not support it", async () => {
        devices.HGZB04D.checkOta.mockResolvedValueOnce({
            available: true,
            current: {...DEFAULT_CURRENT, fileVersion: 10},
            availableMeta: {...DEFAULT_AVAILABLE_META, fileVersion: 14, releaseNotes: "New features"},
        });
        mockMQTTEvents.message(
            "zigbee2mqtt/bridge/request/device/ota_update/check",
            stringify({id: "dimmer_wall_switch", url: "https://example.com/myindex.json"}),
        );
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/check",
            stringify({
                data: {
                    id: "dimmer_wall_switch",
                    update_available: true,
                    downgrade: false,
                    source: "https://example.com/my.ota",
                    release_notes: "New features",
                },
                status: "ok",
            }),
            {},
        );
    });

    it.each([
        "check",
        "check/downgrade",
        "update",
        "update/downgrade",
        "schedule",
        "schedule/downgrade",
        "unschedule",
    ])("refuses to %s when already in progress", async (type) => {
        if (type.includes("schedule") || type.includes("check")) {
            devices.bulb.checkOta.mockImplementationOnce(async () => {
                return await new Promise((resolve) => {
                    setTimeout(
                        () =>
                            resolve({
                                available: false,
                                current: {...DEFAULT_CURRENT, fileVersion: 1},
                            }),
                        99999,
                    );
                });
            });
        } else {
            devices.bulb.updateOta.mockImplementationOnce(async () => {
                return await new Promise((resolve) => {
                    setTimeout(
                        () =>
                            resolve([
                                {...DEFAULT_CURRENT, fileVersion: 1},
                                {...DEFAULT_CURRENT, fileVersion: 10},
                            ]),
                        99999,
                    );
                });
            });
        }

        mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type.includes("schedule") ? "check" : type}`, stringify({id: "bulb"}));
        await flushPromises();
        mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, stringify({id: "bulb"}));
        await flushPromises();

        if (type.includes("schedule") || type.includes("check")) {
            expect(devices.bulb.checkOta).toHaveBeenCalledTimes(1);
        } else {
            expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        }

        await vi.runOnlyPendingTimersAsync();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            `zigbee2mqtt/bridge/response/device/ota_update/${type.replace("/downgrade", "")}`,
            stringify({data: {}, status: "error", error: `OTA update or check for update already in progress for 'bulb'`}),
            {},
        );
    });

    it("does not crash when read modelID before/after OTA update fails", async () => {
        devices.bulb.endpoints[0].read
            .mockRejectedValueOnce("Failed from")
            .mockResolvedValueOnce({}) // configure
            .mockRejectedValueOnce("Failed to")
            .mockResolvedValueOnce({}); // configure
        devices.bulb.updateOta.mockResolvedValueOnce([
            {...DEFAULT_CURRENT, fileVersion: 1},
            {...DEFAULT_CURRENT, fileVersion: 2},
        ]);

        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/update", stringify({id: "bulb"}));
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bridge/response/device/ota_update/update",
            stringify({data: {id: "bulb", from: {file_version: 1}, to: {file_version: 2}}, status: "ok"}),
            {},
        );
    });

    it("checks for update when device requests it", async () => {
        const data = {imageType: 12382, manufacturerCode: 2134, fileVersion: 33};
        devices.bulb.checkOta.mockResolvedValueOnce({
            available: true,
            current: {...DEFAULT_CURRENT, ...data},
            availableMeta: {...DEFAULT_AVAILABLE_META, fileVersion: 34},
        });
        const payload = {
            data,
            cluster: "genOta",
            device: devices.bulb,
            endpoint: devices.bulb.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };
        mockLogger.info.mockClear();
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.checkOta).toHaveBeenCalledWith({downgrade: false}, {...data}, {}, devices.bulb.endpoints[0]);
        expect(mockLogger.info).toHaveBeenCalledWith(`OTA update available for 'bulb'`);
        expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
        expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 0x98}, undefined, 10);

        // Should not request again when device asks again after a short time
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(1);

        mockLogger.info.mockClear();
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(mockLogger.info).not.toHaveBeenCalledWith(`Update available for 'bulb'`);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({
                update: {
                    state: "available",
                    installed_version: 33,
                    latest_version: 34,
                    latest_release_notes: null,
                    latest_source: "https://example.com/my.ota",
                },
            }),
            {retain: true, qos: 0},
        );
    });

    it("responds with NO_IMAGE_AVAILABLE when update available request fails", async () => {
        const data = {imageType: 12382, manufacturerCode: 2134, fileVersion: 33};
        devices.bulb.checkOta.mockRejectedValueOnce("Nothing to find here");
        const payload = {
            data,
            cluster: "genOta",
            device: devices.bulb,
            endpoint: devices.bulb.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.checkOta).toHaveBeenCalledWith({downgrade: false}, {...data}, {}, devices.bulb.endpoints[0]);
        expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
        expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 0x98}, undefined, 10);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {retain: true, qos: 0});
    });

    it("checks for update when device requests it and it is not available", async () => {
        const data = {imageType: 12382, manufacturerCode: 2134, fileVersion: 33};
        devices.bulb.checkOta.mockResolvedValueOnce({
            available: false,
            current: {...DEFAULT_CURRENT, ...data},
            availableMeta: {...DEFAULT_AVAILABLE_META, fileVersion: 33},
        });
        const payload = {
            data,
            cluster: "genOta",
            device: devices.bulb,
            endpoint: devices.bulb.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(1);
        expect(devices.bulb.checkOta).toHaveBeenCalledWith({downgrade: false}, {...data}, {}, devices.bulb.endpoints[0]);
        expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
        expect(devices.bulb.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 0x98}, undefined, 10);
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({
                update: {
                    state: "idle",
                    installed_version: 33,
                    latest_version: 33,
                    latest_release_notes: null,
                    latest_source: "https://example.com/my.ota",
                },
            }),
            {retain: true, qos: 0},
        );
    });

    it("does not check for update when device requests it and disable_automatic_update_check is set to true", async () => {
        settings.set(["ota", "disable_automatic_update_check"], true);
        const data = {imageType: 12382, manufacturerCode: 2134, fileVersion: 33};
        const payload = {
            data,
            cluster: "genOta",
            device: devices.bulb,
            endpoint: devices.bulb.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
    });

    it("does not check for update when device requests it and device disable_automatic_update_check is set to true", async () => {
        settings.set(["devices", "0x000b57fffec6a5b2", "disable_automatic_update_check"], true);
        const data = {imageType: 12382, manufacturerCode: 2134, fileVersion: 33};
        const payload = {
            data,
            cluster: "genOta",
            device: devices.bulb,
            endpoint: devices.bulb.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(devices.bulb.checkOta).toHaveBeenCalledTimes(0);
    });

    it.each(["schedule", "schedule/downgrade"])("schedules and performs an update with topic %s", async (type) => {
        const downgrade = type === "schedule/downgrade";

        if (downgrade) {
            settings.set(["ota", "disable_automatic_update_check"], true); // coverage, scheduling not affected by this
        }

        const data = {imageType: 12382, manufacturerCode: 2134, fileVersion: 1};
        devices.bulb.updateOta.mockImplementationOnce(
            async (source, _requestPayload, _requestTsn, _extraMetas, onProgress, _dataSettings, _endpoint) => {
                expect(source).toStrictEqual(undefined); // scheduled

                onProgress(0, 36000.5678);
                onProgress(10, 3600.2123);
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        ...data,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        ...data,
                        fileVersion: 2,
                    },
                ]);
            },
        );
        mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, stringify({id: "bulb"}));
        await flushPromises();

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            2,
            "zigbee2mqtt/bridge/response/device/ota_update/schedule",
            stringify({data: {id: "bulb"}, status: "ok"}),
            {},
        );

        const payload = {
            data,
            cluster: "genOta",
            device: devices.bulb,
            endpoint: devices.bulb.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };

        await mockZHEvents.message(payload);
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);

        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "updating", progress: 0, remaining: 36001}}),
            {
                retain: true,
                qos: 0,
            },
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 2, latest_version: 2, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith("zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
    });

    it("schedules and cancels an update when no image available", async () => {
        const data = {imageType: 12382, manufacturerCode: 2134, fileVersion: 1};
        devices.bulb.updateOta.mockResolvedValueOnce([{...DEFAULT_CURRENT, ...data}, undefined]);

        mockMQTTEvents.message(
            "zigbee2mqtt/bridge/request/device/ota_update/schedule",
            stringify({id: "bulb", url: "https://example.com/mycustom.ota"}),
        );
        await flushPromises();

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            2,
            "zigbee2mqtt/bridge/response/device/ota_update/schedule",
            stringify({data: {id: "bulb", url: "https://example.com/mycustom.ota"}, status: "ok"}),
            {},
        );

        const payload = {
            data,
            cluster: "genOta",
            device: devices.bulb,
            endpoint: devices.bulb.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };

        await mockZHEvents.message(payload);
        await flushPromises();

        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            3,
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 1, latest_version: 1, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
    });

    it("schedules and re-schedules an update when failed", async () => {
        const data = {imageType: 12382, manufacturerCode: 2134, fileVersion: 1};
        devices.bulb.updateOta
            .mockRejectedValueOnce("Update failed")
            .mockImplementationOnce(async (source, _requestPayload, _requestTsn, _extraMetas, onProgress, _dataSettings, _endpoint) => {
                expect(source).toStrictEqual(undefined); // scheduled

                onProgress(0, 36000.5678);
                onProgress(10, 3600.2123);
                return await Promise.resolve([
                    {
                        ...DEFAULT_CURRENT,
                        ...data,
                        fileVersion: 1,
                    },
                    {
                        ...DEFAULT_CURRENT,
                        ...data,
                        fileVersion: 2,
                    },
                ]);
            });

        mockMQTTEvents.message(
            "zigbee2mqtt/bridge/request/device/ota_update/schedule",
            stringify({id: "bulb", url: "https://example.com/mycustom.ota"}),
        );
        await flushPromises();

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            2,
            "zigbee2mqtt/bridge/response/device/ota_update/schedule",
            stringify({data: {id: "bulb", url: "https://example.com/mycustom.ota"}, status: "ok"}),
            {},
        );

        const payload = {
            data,
            cluster: "genOta",
            device: devices.bulb,
            endpoint: devices.bulb.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };

        await mockZHEvents.message(payload);
        await flushPromises();

        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(1);
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });

        await mockZHEvents.message(payload);
        await flushPromises();
        await vi.advanceTimersByTimeAsync(5100);

        expect(devices.bulb.updateOta).toHaveBeenCalledTimes(2);
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            4,
            "zigbee2mqtt/bulb",
            stringify({update: {state: "updating", progress: 0, remaining: 36001}}),
            {
                retain: true,
                qos: 0,
            },
        );
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            5,
            "zigbee2mqtt/bulb",
            stringify({update: {state: "updating", progress: 10, remaining: 3600}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            6,
            "zigbee2mqtt/bulb",
            stringify({update: {state: "idle", installed_version: 2, latest_version: 2, latest_release_notes: null, latest_source: null}}),
            {retain: true, qos: 0},
        );
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(7, "zigbee2mqtt/bridge/devices", expect.any(String), {retain: true});
    });

    it("overwrites current schedule on re-schedule", async () => {
        for (const [type, overwriteType] of [
            ["schedule", "schedule/downgrade"],
            ["schedule/downgrade", "schedule"],
        ]) {
            mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, stringify({id: "bulb"}));
            await flushPromises();

            expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
                retain: true,
                qos: 0,
            });
            expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
                2,
                "zigbee2mqtt/bridge/response/device/ota_update/schedule",
                stringify({data: {id: "bulb"}, status: "ok"}),
                {},
            );
            expect(devices.bulb.scheduledOta).toStrictEqual({downgrade: type.includes("downgrade")});

            mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${overwriteType}`, stringify({id: "bulb"}));
            await flushPromises();

            expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
                retain: true,
                qos: 0,
            });
            expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
                4,
                "zigbee2mqtt/bridge/response/device/ota_update/schedule",
                stringify({data: {id: "bulb"}, status: "ok"}),
                {},
            );
            expect(devices.bulb.scheduledOta).toStrictEqual({downgrade: overwriteType.includes("downgrade")});
        }
    });

    it.each(["schedule", "schedule/downgrade"])("unschedules", async (type) => {
        mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${type}`, stringify({id: "bulb"}));
        await flushPromises();

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            2,
            "zigbee2mqtt/bridge/response/device/ota_update/schedule",
            stringify({data: {id: "bulb"}, status: "ok"}),
            {},
        );

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

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(3, "zigbee2mqtt/bulb", stringify({update: {state: "idle"}}), {retain: true, qos: 0});

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            4,
            "zigbee2mqtt/bridge/response/device/ota_update/unschedule",
            stringify({data: {id: "bulb"}, status: "ok"}),
            {},
        );
        expect(devices.bulb.scheduledOta).toStrictEqual(undefined);
    });

    it("responds with NO_IMAGE_AVAILABLE when not supporting OTA", async () => {
        const device = devices.HGZB04D;
        const data = {imageType: 12382};
        const payload = {
            data,
            cluster: "genOta",
            device,
            endpoint: device.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
        expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 152}, undefined, 10);
    });

    it("responds with NO_IMAGE_AVAILABLE when not supporting OTA and device has no OTA endpoint to standard endpoint", async () => {
        const device = devices.SV01;
        const data = {imageType: 12382};
        const payload = {
            data,
            cluster: "genOta",
            device,
            endpoint: device.getEndpoint(1)!,
            type: "commandQueryNextImageRequest",
            linkquality: 10,
            meta: {zclTransactionSequenceNumber: 10},
        };
        await mockZHEvents.message(payload);
        await flushPromises();
        expect(device.endpoints[0].commandResponse).toHaveBeenCalledTimes(1);
        expect(device.endpoints[0].commandResponse).toHaveBeenCalledWith("genOta", "queryNextImageResponse", {status: 152}, undefined, 10);
    });

    it("sets given configuration", async () => {
        const setOtaConfigurationSpy = vi.spyOn(zh, "setOtaConfiguration");
        settings.set(["ota", "zigbee_ota_override_index_location"], "local.index.json");
        await resetExtension();
        expect(setOtaConfigurationSpy).toHaveBeenCalledWith(data.mockDir, "local.index.json");
        setOtaConfigurationSpy.mockClear();

        settings.set(["ota", "zigbee_ota_override_index_location"], "http://my.site/index.json");
        await resetExtension();
        expect(setOtaConfigurationSpy).toHaveBeenCalledWith(data.mockDir, "http://my.site/index.json");
        setOtaConfigurationSpy.mockClear();
    });

    it("uses given configuration when updating", async () => {
        settings.set(["ota", "image_block_request_timeout"], 1500000);
        settings.set(["ota", "image_block_response_delay"], 10000);
        settings.set(["ota", "default_maximum_data_size"], 10);
        await resetExtension();

        devices.bulb.updateOta.mockResolvedValueOnce([
            {...DEFAULT_CURRENT, fileVersion: 1},
            {...DEFAULT_CURRENT, fileVersion: 2},
        ]);

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

        expect(devices.bulb.updateOta).toHaveBeenCalledWith(
            {downgrade: false},
            undefined,
            undefined,
            {},
            expect.any(Function),
            {
                responseDelay: 10000,
                baseSize: 10,
                requestTimeout: 1500000,
            },
            undefined,
        );

        settings.set(["ota", "image_block_request_timeout"], 50000);
        settings.set(["ota", "image_block_response_delay"], 50);
        settings.set(["ota", "default_maximum_data_size"], 100);
        await resetExtension();

        devices.bulb.updateOta.mockResolvedValueOnce([
            {...DEFAULT_CURRENT, fileVersion: 1},
            {...DEFAULT_CURRENT, fileVersion: 2},
        ]);

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

        expect(devices.bulb.updateOta).toHaveBeenCalledWith(
            {downgrade: false},
            undefined,
            undefined,
            {},
            expect.any(Function),
            {
                responseDelay: 50,
                baseSize: 100,
                requestTimeout: 50000,
            },
            undefined,
        );
    });

    it("clear update state on startup", async () => {
        const device = controller.zigbee.resolveEntity(devices.bulb_color.ieeeAddr);
        controller.state.set(device, {update: {progress: 100, remaining: 10, state: "updating"}});
        await resetExtension();
        expect(controller.state.get(device)).toStrictEqual({update: {state: "idle"}});
    });

    it("[DEPRECATED] handles message as device id string", async () => {
        mockMQTTEvents.message("zigbee2mqtt/bridge/request/device/ota_update/schedule", "bulb");
        await flushPromises();

        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(1, "zigbee2mqtt/bulb", stringify({update: {state: "scheduled"}}), {
            retain: true,
            qos: 0,
        });
        expect(mockMQTTPublishAsync).toHaveBeenNthCalledWith(
            2,
            "zigbee2mqtt/bridge/response/device/ota_update/schedule",
            stringify({data: {id: "bulb"}, status: "ok"}),
            {},
        );
    });

    it.each(["check", "update", "schedule"])("[DEPRECATED] does not OTA %s when device does not support it", async (api) => {
        mockMQTTEvents.message(`zigbee2mqtt/bridge/request/device/ota_update/${api}`, "dimmer_wall_switch");
        await flushPromises();
        expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
            `zigbee2mqtt/bridge/response/device/ota_update/${api}`,
            stringify({data: {}, status: "error", error: `Device 'dimmer_wall_switch' does not support OTA updates`}),
            {},
        );
    });
});
