import {existsSync, mkdirSync} from "node:fs";
import {bench, describe, vi} from "vitest";
import {type Controller, Zcl, Zdo, ZSpec} from "zigbee-herdsman";
import type Adapter from "zigbee-herdsman/dist/adapter/adapter";
import type {ZclPayload} from "zigbee-herdsman/dist/adapter/events";
import {Device, InterviewState} from "zigbee-herdsman/dist/controller/model/device";
import {Endpoint} from "zigbee-herdsman/dist/controller/model/endpoint";
import Entity from "zigbee-herdsman/dist/controller/model/entity";
import {Group} from "zigbee-herdsman/dist/controller/model/group";
import type {DeviceType} from "zigbee-herdsman/dist/controller/tstype";
import {Foundation} from "zigbee-herdsman/dist/zspec/zcl/definition/foundation";
import type {RequestToResponseMap} from "zigbee-herdsman/dist/zspec/zdo/definition/tstypes";
import data from "../lib/util/data";
import {stringify} from "../lib/util/stringify";
import {BENCH_OPTIONS} from "./benchOptions";

vi.doMock("zigbee-herdsman", async (importOriginal) => {
    const actual = await importOriginal<typeof import("zigbee-herdsman")>();
    class MockHerdsman {
        on: Controller["on"] = vi.fn();
        start: Controller["start"] = async () => "resumed" as const;
        stop: Controller["stop"] = async () => {};
        isStopping: Controller["isStopping"] = () => false;
        getCoordinatorVersion: Controller["getCoordinatorVersion"] = async () =>
            Promise.resolve({
                type: "Dummy",
                meta: {revision: "9.9.9"},
            });
        getNetworkParameters: Controller["getNetworkParameters"] = async () => Promise.resolve({...NETWORK_PARAMS});
        getPermitJoin: Controller["getPermitJoin"] = () => false;
        getPermitJoinEnd: Controller["getPermitJoinEnd"] = () => undefined;
        getDeviceByIeeeAddr: Controller["getDeviceByIeeeAddr"] = (ieeeAddr) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr);
        getGroupByID: Controller["getGroupByID"] = (id) => ZH_GROUPS.find((group) => group.groupID === id);
        getDevicesByType: Controller["getDevicesByType"] = (type) => ZH_DEVICES.filter((device) => device.type === type);
        getDeviceByNetworkAddress: Controller["getDeviceByNetworkAddress"] = (networkAddress) =>
            ZH_DEVICES.find((device) => device.networkAddress === networkAddress);
        *getDevicesIterator(predicate: ((device: Device) => boolean) | undefined) {
            for (const device of ZH_DEVICES) {
                if (!predicate || predicate(device)) {
                    yield device;
                }
            }
        }
        *getGroupsIterator(predicate: ((group: Group) => boolean) | undefined) {
            for (const group of ZH_GROUPS) {
                if (!predicate || predicate(group)) {
                    yield group;
                }
            }
        }
    }

    return {
        ...actual,
        Controller: MockHerdsman,
    };
});

process.env.ZIGBEE2MQTT_DATA = "data-bench";
data._testReload();

if (!existsSync(data.getPath())) {
    mkdirSync(data.getPath(), {recursive: true});
}

const createEndpoint = (id: number, ieeeAddr: string, networkAddress: number) => {
    const ep = Endpoint.create(
        id,
        id === ZSpec.GP_ENDPOINT ? ZSpec.GP_PROFILE_ID : ZSpec.HA_PROFILE_ID,
        id === ZSpec.GP_ENDPOINT ? 0x66 : 0x65,
        id === ZSpec.GP_ENDPOINT ? [Zcl.Clusters.greenPower.ID] : [Zcl.Clusters.genBasic.ID, Zcl.Clusters.genOnOff.ID],
        id === ZSpec.GP_ENDPOINT ? [Zcl.Clusters.greenPower.ID] : [Zcl.Clusters.genBasic.ID],
        networkAddress,
        ieeeAddr,
    );
    ep.save = () => {};

    return ep;
};
// `Device.create` requires `Database`, bypass it by using the private constructor directly
const createDevice = (
    dbId: number,
    type: DeviceType,
    ieeeAddr: string,
    networkAddress: number,
    manufacturerID: number | undefined,
    manufacturerName: string | undefined,
    powerSource: string | undefined,
    modelID: string | undefined,
): Device => {
    const haEp = createEndpoint(ZSpec.HA_ENDPOINT, ieeeAddr, networkAddress);
    const ep2 = createEndpoint(2, ieeeAddr, networkAddress);
    const endpoints = [haEp, ep2];

    if (type === "Coordinator" || type === "Router") {
        const gpEp = createEndpoint(ZSpec.GP_ENDPOINT, ieeeAddr, networkAddress);

        endpoints.push(gpEp);
    }

    // @ts-expect-error mocking private
    const device = new Device(
        dbId,
        type,
        ieeeAddr,
        networkAddress,
        manufacturerID,
        endpoints,
        manufacturerName,
        powerSource,
        modelID,
        undefined,
        undefined,
        undefined,
        undefined,
        undefined,
        undefined,
        InterviewState.Successful,
        {},
        undefined,
        undefined,
        0,
        undefined,
    );

    // in-memory only
    device.save = () => {};

    return device;
};
// `Group.create` requires `Database`, bypass it by using the private constructor directly
const createGroup = (dbId: number, id: number): Group => {
    // @ts-expect-error mocking private
    const group = new Group(dbId, id, [], {});

    // in-memory only
    group.save = () => {};

    return group;
};

const COORD_IEEE = "0x0101010101010101";
const EXT_PAN_ID = [0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd, 0xdd];
const NETWORK_PARAMS = {
    panID: 0x1a62,
    extendedPanID: `0x${Buffer.from(EXT_PAN_ID).toString("hex")}`,
    channel: 11,
    nwkUpdateID: 1,
};
const NETWORK_KEY = [1, 3, 5, 7, 9, 11, 13, 15, 0, 2, 4, 6, 8, 10, 12, 13];
const ZH_DEVICES: Device[] = [];
const ZH_GROUPS: Group[] = [];
const MANY_DEVICES = 100;

const initDevices = () => {
    ZH_DEVICES.splice(0, ZH_DEVICES.length);

    ZH_DEVICES.push(
        createDevice(
            0,
            "Coordinator",
            COORD_IEEE,
            ZSpec.COORDINATOR_ADDRESS,
            Zcl.ManufacturerCode.SILICON_LABORATORIES,
            undefined,
            undefined,
            undefined,
        ),
    );
    ZH_DEVICES.push(
        createDevice(1, "Router", "0xf1f1f1f1f1f1f1f1", 0x0001, Zcl.ManufacturerCode.INNR_LIGHTING_BV, "Innr", "Mains (single phase)", "AE 262"),
    );
    ZH_DEVICES.push(
        createDevice(2, "EndDevice", "0xe2e2e2e2e2e2e2e2", 0x0002, Zcl.ManufacturerCode.TUYA_GLOBAL_INC, "_TYZB01_kvwjujy9", "Battery", "TS0222"),
    );
    ZH_DEVICES.push(createDevice(3, "GreenPower", "0x00000000015d3d3d", 0x0003, undefined, undefined, undefined, "GreenPower_7"));
    // these have configure, without setTimeout, they hammer really badly (# of fn calls), so, only one of each
    ZH_DEVICES.push(
        createDevice(
            4,
            "Router",
            "0xd3d3d3d3d3d3d3d3",
            0x0004,
            Zcl.ManufacturerCode.LEDVANCE_GMBH,
            "LEDVANCE",
            "Mains (single phase)",
            "PLUG OUTDOOR EU T",
        ),
    );
    ZH_DEVICES.push(
        createDevice(5, "Router", "0xc4c4c4c4c4c4c4c4", 0x0005, Zcl.ManufacturerCode.INOVELLI, "Inovelli", "Mains (single phase)", "VZM35-SN"),
    );
    ZH_DEVICES.push(
        createDevice(
            6,
            "Router",
            "0xb5b5b5b5b5b5b5b5",
            0x0006,
            Zcl.ManufacturerCode.SILICON_LABORATORIES,
            "SMLIGHT",
            "Mains (single phase)",
            "SLZB-06Mg24",
        ),
    );
    ZH_DEVICES.push(
        createDevice(
            7,
            "Router",
            "0xa6a6a6a6a6a6a6a6",
            0x0007,
            Zcl.ManufacturerCode.TUYA_GLOBAL_INC,
            "_TZE200_p0gzbqct",
            "Mains (single phase)",
            "TS0601",
        ),
    );
};

const initGroups = () => {
    ZH_GROUPS.splice(0, ZH_GROUPS.length);

    ZH_GROUPS.push(createGroup(0, 1));
};

const addManyDevices = () => {
    for (let i = 0; i < MANY_DEVICES; i++) {
        const ieee = `0xf1f1f1f1f1f1f1${i.toString(16).padStart(2, "0")}`;

        // device without `configure` (too many calls otherwise)
        ZH_DEVICES.push(createDevice(1, "Router", ieee, 0x0008 + i, Zcl.ManufacturerCode.INNR_LIGHTING_BV, "Innr", "Mains (single phase)", "AE 262"));
    }
};

const getMidDeviceIeee = () =>
    `0xf1f1f1f1f1f1f1${Math.floor(MANY_DEVICES / 2)
        .toString(16)
        .padStart(2, "0")}`;

Device.byIeeeAddr = (ieeeAddr, _includeDeleted) => ZH_DEVICES.find((device) => device.ieeeAddr === ieeeAddr);
Device.byType = (type) => ZH_DEVICES.filter((device) => device.type === type);

const adapter = {
    sendZclFrameToEndpoint: async (
        _ieeeAddr: string,
        networkAddress: number,
        endpoint: number,
        zclFrame: Zcl.Frame,
        _timeout: number,
        disableResponse: boolean,
        _disableRecovery: boolean,
        sourceEndpoint?: number,
    ): Promise<ZclPayload | undefined> => {
        const payload: {[key: string]: unknown}[] = [];

        if (!disableResponse) {
            if (zclFrame.header.isGlobal) {
                switch (zclFrame.command.ID) {
                    case Foundation.read.ID: {
                        for (const attr of zclFrame.payload) {
                            const attribute = Zcl.Utils.getClusterAttribute(zclFrame.cluster, attr.attrId, undefined);

                            if (attribute && attribute.type !== Zcl.DataType.NO_DATA && attribute.type < Zcl.DataType.OCTET_STR) {
                                payload.push({
                                    attrId: attr.attrId,
                                    dataType: attribute.type,
                                    attrData: 1,
                                    status: 0,
                                });
                            }
                        }

                        const messageContents = Zcl.Frame.create(
                            0,
                            Zcl.Direction.SERVER_TO_CLIENT,
                            true,
                            undefined,
                            10,
                            Foundation.readRsp.ID,
                            zclFrame.cluster.ID,
                            payload,
                            {},
                        ).toBuffer();

                        return await Promise.resolve({
                            clusterID: zclFrame.cluster.ID,
                            header: Zcl.Header.fromBuffer(messageContents),
                            address: networkAddress,
                            data: messageContents,
                            endpoint: sourceEndpoint ?? 1,
                            linkquality: 200,
                            groupID: 0,
                            wasBroadcast: false,
                            destinationEndpoint: endpoint,
                        });
                    }
                    case Foundation.configReport.ID: {
                        for (const item of zclFrame.payload) {
                            payload.push({attrId: item.attrId, status: 0, direction: 1});
                        }

                        const messageContents = Zcl.Frame.create(
                            0,
                            Zcl.Direction.SERVER_TO_CLIENT,
                            true,
                            undefined,
                            10,
                            Foundation.configReportRsp.ID,
                            zclFrame.cluster.ID,
                            payload,
                            {},
                        ).toBuffer();

                        return await Promise.resolve({
                            clusterID: zclFrame.cluster.ID,
                            header: Zcl.Header.fromBuffer(messageContents),
                            address: networkAddress,
                            data: messageContents,
                            endpoint: sourceEndpoint ?? 1,
                            linkquality: 200,
                            groupID: 0,
                            wasBroadcast: false,
                            destinationEndpoint: endpoint,
                        });
                    }
                }
            }
        }

        return await Promise.resolve(undefined);
    },
    sendZclFrameToGroup: async (_groupID: number, _zclFrame: Zcl.Frame, _sourceEndpoint?: number): Promise<void> => await Promise.resolve(),
    sendZdo: async (
        _ieeeAddress: string,
        _networkAddress: number,
        clusterId: Zdo.ClusterId,
        _payload: Buffer,
        _disableResponse: boolean,
    ): Promise<RequestToResponseMap[keyof RequestToResponseMap] | undefined> => {
        switch (clusterId) {
            case Zdo.ClusterId.BIND_REQUEST:
            case Zdo.ClusterId.UNBIND_REQUEST: {
                return await Promise.resolve([Zdo.Status.SUCCESS, undefined]);
            }
        }

        return await Promise.resolve(undefined);
    },
};

Entity.injectAdapter(adapter as Adapter);

// use plain type to avoid early import that otherwise messes with data path
let controller: import("../lib/controller.js").Controller;
const origSetImmediate = global.setImmediate;
const origSetTimeout = global.setTimeout;

const mockGlobalThis = () => {
    const setImmediateProms: (void | Promise<void>)[] = [];
    const setTimeoutProms: (void | Promise<void>)[] = [];

    // @ts-expect-error mock
    globalThis.setImmediate = (callback: () => void) => {
        setImmediateProms.push(callback());
    };
    // @ts-expect-error mock
    globalThis.setTimeout = (callback: () => void) => {
        setTimeoutProms.push(callback());
    };

    return {setImmediateProms, setTimeoutProms};
};

const unmockGlobalThis = () => {
    globalThis.setImmediate = origSetImmediate;
    globalThis.setTimeout = origSetTimeout;
};

const settle = async (mockedGlobal: ReturnType<typeof mockGlobalThis>) => {
    await Promise.allSettled(mockedGlobal.setImmediateProms);
    await Promise.allSettled(mockedGlobal.setTimeoutProms);
    await new Promise((resolve) => origSetImmediate(resolve));
};

const initSettings = async (pathValuePairs?: [string[], string | number | boolean][]) => {
    const settings = await import("../lib/util/settings.js");

    settings.writeMinimalDefaults();
    // disable logging, too much influence on perf
    settings.set(["advanced", "log_level"], "error");
    settings.set(["advanced", "log_output"], []);
    settings.set(["advanced", "pan_id"], NETWORK_PARAMS.panID);
    settings.set(["advanced", "ext_pan_id"], EXT_PAN_ID);
    settings.set(["advanced", "network_key"], NETWORK_KEY);

    if (pathValuePairs) {
        for (const [path, value] of pathValuePairs) {
            settings.set(path, value);
        }
    }
};

const initController = async () => {
    const {Controller} = await import("../lib/controller.js");

    controller = new Controller(
        async () => {},
        async () => {},
    );

    // all dummies, can trigger `controller.mqtt.onMessage(topic, message)` as needed
    // @ts-expect-error mocking private
    controller.mqtt.client = {
        options: {
            protocolVersion: 5,
            protocol: "mqtt",
            host: "localhost",
            port: 1883,
        },
        queue: [],
        reconnecting: false,
        disconnecting: false,
        disconnected: false,
        endAsync: async () => {},
        publishAsync: async () => {},
    };
    controller.mqtt.connect = async () => {
        // @ts-expect-error private
        await controller.mqtt.onConnect();
    };
    controller.mqtt.subscribe = async () => {};
    controller.mqtt.unsubscribe = async () => {};

    // will be in-memory only
    controller.state.start = () => {};
    controller.state.stop = () => {};
};

describe("Controller with dummy zigbee/mqtt", () => {
    bench(
        "[defaults] start & stop controller",
        async () => {
            initDevices();
            initGroups();
            await initSettings();
            await initController();
            const mockedGlobal = mockGlobalThis();

            await controller.start();
            await settle(mockedGlobal);

            if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
                throw new Error("Invalid");
            }

            await controller.stop();
            unmockGlobalThis();
        },
        BENCH_OPTIONS,
    );

    bench(
        "[HA] start & stop controller",
        async () => {
            initDevices();
            initGroups();
            await initSettings([[["homeassistant", "enabled"], true]]);
            await initController();
            const mockedGlobal = mockGlobalThis();

            await controller.start();
            controller.mqtt.onMessage("homeassistant/status", Buffer.from("online", "utf8"));
            await settle(mockedGlobal);

            if ((await controller.zigbee.getCoordinatorVersion()).type !== "Dummy") {
                throw new Error("Invalid");
            }

            await controller.stop();
            unmockGlobalThis();
        },
        BENCH_OPTIONS,
    );

    describe("defaults runtime", () => {
        const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
            BENCH_OPTIONS.setup!(task, mode);
            initDevices();
            initGroups();
            await initSettings();
            await initController();
            const mockedGlobal = mockGlobalThis();

            await controller.start();
            await settle(mockedGlobal);
        };
        const teardown = async () => {
            await controller.stop();
            unmockGlobalThis();
        };

        bench(
            "[defaults] receive device message",
            async () => {
                const mockedGlobal = mockGlobalThis();

                controller.eventBus.emitDeviceMessage({
                    type: "attributeReport",
                    device: controller.zigbee.resolveEntity("0xf1f1f1f1f1f1f1f1"),
                    endpoint: ZSpec.HA_ENDPOINT,
                    linkquality: 200,
                    groupID: 0,
                    cluster: "genOnOff",
                    data: {onOff: 1},
                    meta: {},
                });
                await settle(mockedGlobal);
            },
            {...BENCH_OPTIONS, setup, teardown},
        );

        bench(
            "[defaults] receive MQTT message",
            async () => {
                const mockedGlobal = mockGlobalThis();

                controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
                await settle(mockedGlobal);
            },
            {...BENCH_OPTIONS, setup, teardown},
        );

        bench(
            "[defaults] add group member",
            async () => {
                const mockedGlobal = mockGlobalThis();

                controller.eventBus.emitMQTTMessage({
                    topic: "zigbee2mqtt/bridge/request/group/members/add",
                    message: stringify({
                        device: "0xf1f1f1f1f1f1f1f1",
                        group: `${ZH_GROUPS[0].groupID}`,
                        endpoint: ZSpec.HA_ENDPOINT,
                    }),
                });

                await settle(mockedGlobal);

                if (ZH_GROUPS[0].members.length !== 1) {
                    throw new Error("Invalid state");
                }
            },
            {...BENCH_OPTIONS, setup, teardown},
        );
    });

    describe("defaults/stress runtime", () => {
        const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
            BENCH_OPTIONS.setup!(task, mode);
            initDevices();
            initGroups();
            addManyDevices();
            await initSettings();
            await initController();
            const mockedGlobal = mockGlobalThis();

            await controller.start();
            await settle(mockedGlobal);
        };

        const teardown = async () => {
            await controller.stop();
            unmockGlobalThis();
        };

        // this is mostly just to confirm the number of devices does not influence the processing (much)
        bench(
            "[defaults/stress] receive device message",
            async () => {
                const mockedGlobal = mockGlobalThis();

                controller.eventBus.emitDeviceMessage({
                    type: "attributeReport",
                    device: controller.zigbee.resolveEntity(getMidDeviceIeee()),
                    endpoint: ZSpec.HA_ENDPOINT,
                    linkquality: 200,
                    groupID: 0,
                    cluster: "genOnOff",
                    data: {onOff: 1},
                    meta: {},
                });
                await settle(mockedGlobal);
            },
            {...BENCH_OPTIONS, setup, teardown},
        );
    });

    describe("HA runtime", () => {
        const setup: NonNullable<Parameters<typeof bench>[2]>["setup"] = async (task, mode) => {
            BENCH_OPTIONS.setup!(task, mode);
            initDevices();
            initGroups();
            await initSettings([[["homeassistant", "enabled"], true]]);
            await initController();
            const mockedGlobal = mockGlobalThis();

            await controller.start();
            controller.mqtt.onMessage("homeassistant/status", Buffer.from("online", "utf8"));
            await settle(mockedGlobal);
        };

        const teardown = async () => {
            await controller.stop();
            unmockGlobalThis();
        };

        bench(
            "[HA] receive device message",
            async () => {
                const mockedGlobal = mockGlobalThis();

                controller.eventBus.emitDeviceMessage({
                    type: "attributeReport",
                    device: controller.zigbee.resolveEntity("0xf1f1f1f1f1f1f1f1"),
                    endpoint: ZSpec.HA_ENDPOINT,
                    linkquality: 200,
                    groupID: 0,
                    cluster: "genOnOff",
                    data: {onOff: 1},
                    meta: {},
                });
                await settle(mockedGlobal);
            },
            {...BENCH_OPTIONS, setup, teardown},
        );

        bench(
            "[HA] receive MQTT message",
            async () => {
                const mockedGlobal = mockGlobalThis();

                controller.mqtt.onMessage("zigbee2mqtt/0xf1f1f1f1f1f1f1f1/set", Buffer.from(`{"state": "OFF"}`, "utf8"));
                await settle(mockedGlobal);
            },
            {...BENCH_OPTIONS, setup, teardown},
        );

        bench(
            "[HA] receive MQTT discovery message",
            async () => {
                const mockedGlobal = mockGlobalThis();

                controller.mqtt.onMessage(
                    "homeassistant/sensor/0xe2e2e2e2e2e2e2e2/update/config",
                    Buffer.from(stringify({availability: [{topic: "zigbee2mqtt/bridge/state", value_template: "{{ value_json.state }}"}]}), "utf8"),
                );
                await settle(mockedGlobal);
            },
            {...BENCH_OPTIONS, setup, teardown},
        );
    });
});
