import assert from "node:assert";

import {logger} from "../../utils/logger";
import * as Zcl from "../../zspec/zcl";
import zclTransactionSequenceNumber from "../helpers/zclTransactionSequenceNumber";
import type {DatabaseEntry, KeyValue} from "../tstype";
import Device from "./device";
import type Endpoint from "./endpoint";
import Entity from "./entity";

const NS = "zh:controller:group";

interface Options {
    manufacturerCode?: number;
    direction?: Zcl.Direction;
    srcEndpoint?: number;
    reservedBits?: number;
    transactionSequenceNumber?: number;
}

interface OptionsWithDefaults extends Options {
    direction: Zcl.Direction;
    reservedBits: number;
}

export class Group extends Entity {
    private databaseID: number;
    public readonly groupID: number;
    private readonly _members: Endpoint[];
    // Can be used by applications to store data.
    public readonly meta: KeyValue;

    // This lookup contains all groups that are queried from the database, this is to ensure that always
    // the same instance is returned.
    private static readonly groups: Map<number /* groupID */, Group> = new Map();
    private static loadedFromDatabase = false;

    /** Member endpoints with valid devices (not unknown/deleted) */
    get members(): Endpoint[] {
        return this._members.filter((e) => e.getDevice() !== undefined);
    }

    private constructor(databaseID: number, groupID: number, members: Endpoint[], meta: KeyValue) {
        super();
        this.databaseID = databaseID;
        this.groupID = groupID;
        this._members = members;
        this.meta = meta;
    }

    /*
     * CRUD
     */

    /**
     * Reset runtime lookups.
     */
    public static resetCache(): void {
        Group.groups.clear();
        Group.loadedFromDatabase = false;
    }

    private static fromDatabaseEntry(entry: DatabaseEntry): Group {
        // db is expected to never contain duplicate, so no need for explicit check
        const members: Endpoint[] = [];

        for (const member of entry.members) {
            const device = Device.byIeeeAddr(member.deviceIeeeAddr);

            if (device) {
                const endpoint = device.getEndpoint(member.endpointID);

                if (endpoint) {
                    members.push(endpoint);
                }
            }
        }

        return new Group(entry.id, entry.groupID, members, entry.meta);
    }

    private toDatabaseRecord(): DatabaseEntry {
        const members: DatabaseEntry["members"] = [];

        for (const member of this._members) {
            const device = member.getDevice();

            if (device) {
                members.push({deviceIeeeAddr: device.ieeeAddr, endpointID: member.ID});
            }
        }

        return {id: this.databaseID, type: "Group", groupID: this.groupID, members, meta: this.meta};
    }

    private static loadFromDatabaseIfNecessary(): void {
        if (!Group.loadedFromDatabase) {
            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            for (const entry of Entity.database!.getEntriesIterator(["Group"])) {
                const group = Group.fromDatabaseEntry(entry);
                Group.groups.set(group.groupID, group);
            }

            Group.loadedFromDatabase = true;
        }
    }

    public static byGroupID(groupID: number): Group | undefined {
        Group.loadFromDatabaseIfNecessary();
        return Group.groups.get(groupID);
    }

    /**
     * @deprecated use allIterator()
     */
    public static all(): Group[] {
        Group.loadFromDatabaseIfNecessary();
        return Array.from(Group.groups.values());
    }

    public static *allIterator(predicate?: (value: Group) => boolean): Generator<Group> {
        Group.loadFromDatabaseIfNecessary();

        for (const group of Group.groups.values()) {
            if (!predicate || predicate(group)) {
                yield group;
            }
        }
    }

    public static create(groupID: number): Group {
        assert(typeof groupID === "number", "GroupID must be a number");
        // Don't allow groupID 0, from the spec:
        // "Scene identifier 0x00, along with group identifier 0x0000, is reserved for the global scene used by the OnOff cluster"
        assert(groupID >= 1, "GroupID must be at least 1");

        Group.loadFromDatabaseIfNecessary();

        if (Group.groups.has(groupID)) {
            throw new Error(`Group with groupID '${groupID}' already exists`);
        }

        // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
        const databaseID = Entity.database!.newID();
        const group = new Group(databaseID, groupID, [], {});
        // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
        Entity.database!.insert(group.toDatabaseRecord());

        Group.groups.set(group.groupID, group);
        return group;
    }

    public async removeFromNetwork(): Promise<void> {
        for (const endpoint of this._members) {
            await endpoint.removeFromGroup(this);
        }

        this.removeFromDatabase();
    }

    public removeFromDatabase(): void {
        Group.loadFromDatabaseIfNecessary();

        // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
        if (Entity.database!.has(this.databaseID)) {
            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            Entity.database!.remove(this.databaseID);
        }

        Group.groups.delete(this.groupID);
    }

    public save(writeDatabase = true): void {
        // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
        Entity.database!.update(this.toDatabaseRecord(), writeDatabase);
    }

    public addMember(endpoint: Endpoint): void {
        if (!this._members.includes(endpoint)) {
            this._members.push(endpoint);
            this.save();
        }
    }

    public removeMember(endpoint: Endpoint): void {
        const i = this._members.indexOf(endpoint);

        if (i > -1) {
            this._members.splice(i, 1);
            this.save();
        }
    }

    public hasMember(endpoint: Endpoint): boolean {
        return this._members.includes(endpoint);
    }

    /*
     * Zigbee functions
     */

    public async write(clusterKey: number | string, attributes: KeyValue, options?: Options): Promise<void> {
        const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER);
        const cluster = Zcl.Utils.getCluster(clusterKey, undefined, {});
        const payload: {attrId: number; dataType: number; attrData: number | string | boolean}[] = [];

        for (const [nameOrID, value] of Object.entries(attributes)) {
            if (cluster.hasAttribute(nameOrID)) {
                const attribute = cluster.getAttribute(nameOrID);
                payload.push({attrId: attribute.ID, attrData: value, dataType: attribute.type});
            } else if (!Number.isNaN(Number(nameOrID))) {
                payload.push({attrId: Number(nameOrID), attrData: value.value, dataType: value.type});
            } else {
                throw new Error(`Unknown attribute '${nameOrID}', specify either an existing attribute or a number`);
            }
        }

        const createLogMessage = (): string =>
            `Write ${this.groupID} ${cluster.name}(${JSON.stringify(attributes)}, ${JSON.stringify(optionsWithDefaults)})`;
        logger.debug(createLogMessage, NS);

        try {
            const frame = Zcl.Frame.create(
                Zcl.FrameType.GLOBAL,
                optionsWithDefaults.direction,
                true,
                optionsWithDefaults.manufacturerCode,
                optionsWithDefaults.transactionSequenceNumber ?? zclTransactionSequenceNumber.next(),
                "write",
                cluster.ID,
                payload,
                {},
                optionsWithDefaults.reservedBits,
            );

            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            await Entity.adapter!.sendZclFrameToGroup(this.groupID, frame, optionsWithDefaults.srcEndpoint);
        } catch (error) {
            const err = error as Error;
            err.message = `${createLogMessage()} failed (${err.message})`;
            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            logger.debug(err.stack!, NS);

            throw error;
        }
    }

    public async read(clusterKey: number | string, attributes: (string | number)[], options?: Options): Promise<void> {
        const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER);
        const cluster = Zcl.Utils.getCluster(clusterKey, undefined, {});
        const payload: {attrId: number}[] = [];

        for (const attribute of attributes) {
            payload.push({attrId: typeof attribute === "number" ? attribute : cluster.getAttribute(attribute).ID});
        }

        const frame = Zcl.Frame.create(
            Zcl.FrameType.GLOBAL,
            optionsWithDefaults.direction,
            true,
            optionsWithDefaults.manufacturerCode,
            optionsWithDefaults.transactionSequenceNumber ?? zclTransactionSequenceNumber.next(),
            "read",
            cluster.ID,
            payload,
            {},
            optionsWithDefaults.reservedBits,
        );

        const createLogMessage = (): string =>
            `Read ${this.groupID} ${cluster.name}(${JSON.stringify(attributes)}, ${JSON.stringify(optionsWithDefaults)})`;
        logger.debug(createLogMessage, NS);

        try {
            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            await Entity.adapter!.sendZclFrameToGroup(this.groupID, frame, optionsWithDefaults.srcEndpoint);
        } catch (error) {
            const err = error as Error;
            err.message = `${createLogMessage()} failed (${err.message})`;
            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            logger.debug(err.stack!, NS);

            throw error;
        }
    }

    public async command(clusterKey: number | string, commandKey: number | string, payload: KeyValue, options?: Options): Promise<void> {
        const optionsWithDefaults = this.getOptionsWithDefaults(options, Zcl.Direction.CLIENT_TO_SERVER);
        const cluster = Zcl.Utils.getCluster(clusterKey, undefined, {});
        const command = cluster.getCommand(commandKey);

        const createLogMessage = (): string => `Command ${this.groupID} ${cluster.name}.${command.name}(${JSON.stringify(payload)})`;
        logger.debug(createLogMessage, NS);

        try {
            const frame = Zcl.Frame.create(
                Zcl.FrameType.SPECIFIC,
                optionsWithDefaults.direction,
                true,
                optionsWithDefaults.manufacturerCode,
                optionsWithDefaults.transactionSequenceNumber || zclTransactionSequenceNumber.next(),
                command.ID,
                cluster.ID,
                payload,
                {},
                optionsWithDefaults.reservedBits,
            );

            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            await Entity.adapter!.sendZclFrameToGroup(this.groupID, frame, optionsWithDefaults.srcEndpoint);
        } catch (error) {
            const err = error as Error;
            err.message = `${createLogMessage()} failed (${err.message})`;
            // biome-ignore lint/style/noNonNullAssertion: ignored using `--suppress`
            logger.debug(err.stack!, NS);

            throw error;
        }
    }

    private getOptionsWithDefaults(options: Options | undefined, direction: Zcl.Direction): OptionsWithDefaults {
        return {
            direction,
            srcEndpoint: undefined,
            reservedBits: 0,
            manufacturerCode: undefined,
            transactionSequenceNumber: undefined,
            ...(options || {}),
        };
    }
}

export default Group;
