import fs from "node:fs";
import bind from "bind-decorator";
import type {IClientOptions, IClientPublishOptions, MqttClient} from "mqtt";
import {connectAsync} from "mqtt";
import type {Zigbee2MQTTAPI} from "./types/api";

import logger from "./util/logger";
import * as settings from "./util/settings";
import utils from "./util/utils";

const NS = "z2m:mqtt";

export interface MqttPublishOptions {
    clientOptions: IClientPublishOptions;
    baseTopic: string;
    skipLog: boolean;
    skipReceive: boolean;
    meta: {isEntityState?: boolean};
}

export default class Mqtt {
    private publishedTopics = new Set<string>();
    private connectionTimer?: NodeJS.Timeout;
    private client!: MqttClient;
    private eventBus: EventBus;
    private republishRetainedTimer?: NodeJS.Timeout;
    private defaultPublishOptions: MqttPublishOptions;
    public retainedMessages: {[s: string]: {topic: string; payload: string; options: MqttPublishOptions}} = {};

    get info() {
        return {
            version: this.client.options.protocolVersion,
            server: `${this.client.options.protocol}://${this.client.options.host}:${this.client.options.port}`,
        };
    }

    get stats() {
        return {
            connected: this.isConnected(),
            queued: this.client.queue.length,
        };
    }

    constructor(eventBus: EventBus) {
        this.eventBus = eventBus;
        this.defaultPublishOptions = {
            clientOptions: {},
            baseTopic: settings.get().mqtt.base_topic,
            skipLog: false,
            skipReceive: true,
            meta: {},
        };
    }

    async connect(): Promise<void> {
        const mqttSettings = settings.get().mqtt;

        logger.info(`Connecting to MQTT server at ${mqttSettings.server}`);

        const options: IClientOptions = {
            will: {
                topic: `${settings.get().mqtt.base_topic}/bridge/state`,
                payload: Buffer.from(JSON.stringify({state: "offline"})),
                retain: !settings.get().mqtt.force_disable_retain,
                qos: 1,
            },
            properties: {maximumPacketSize: mqttSettings.maximum_packet_size},
        };

        if (mqttSettings.version) {
            options.protocolVersion = mqttSettings.version;
        }

        if (mqttSettings.keepalive) {
            logger.debug(`Using MQTT keepalive: ${mqttSettings.keepalive}`);
            options.keepalive = mqttSettings.keepalive;
        }

        if (mqttSettings.ca) {
            logger.debug(`MQTT SSL/TLS: Path to CA certificate = ${mqttSettings.ca}`);
            options.ca = fs.readFileSync(mqttSettings.ca);
        }

        if (mqttSettings.key && mqttSettings.cert) {
            logger.debug(`MQTT SSL/TLS: Path to client key = ${mqttSettings.key}`);
            logger.debug(`MQTT SSL/TLS: Path to client certificate = ${mqttSettings.cert}`);
            options.key = fs.readFileSync(mqttSettings.key);
            options.cert = fs.readFileSync(mqttSettings.cert);
        }

        if (mqttSettings.user && mqttSettings.password) {
            logger.debug(`Using MQTT login with username: ${mqttSettings.user}`);
            options.username = mqttSettings.user;
            options.password = mqttSettings.password;
        } else if (mqttSettings.user) {
            logger.debug(`Using MQTT login with username only: ${mqttSettings.user}`);
            options.username = mqttSettings.user;
        } else {
            logger.debug("Using MQTT anonymous login");
        }

        if (mqttSettings.client_id) {
            logger.debug(`Using MQTT client ID: '${mqttSettings.client_id}'`);
            options.clientId = mqttSettings.client_id;
        }

        if (mqttSettings.reject_unauthorized !== undefined && !mqttSettings.reject_unauthorized) {
            logger.debug("MQTT reject_unauthorized set false, ignoring certificate warnings.");
            options.rejectUnauthorized = false;
        }

        if (mqttSettings.server_name) {
            logger.debug(`MQTT SSL/TLS: SNI server name = ${mqttSettings.server_name}`);
            options.servername = mqttSettings.server_name;
        }

        this.client = await connectAsync(mqttSettings.server, options);

        // https://github.com/Koenkk/zigbee2mqtt/issues/9822
        this.client.stream.setMaxListeners(0);

        this.client.on("error", (err) => {
            logger.error(`MQTT error: ${err.message}`);
        });

        if (mqttSettings.version != null && mqttSettings.version >= 5) {
            this.client.on("disconnect", (packet) => {
                logger.error(`MQTT disconnect: reason ${packet.reasonCode} (${packet.properties?.reasonString})`);
            });
        }

        this.client.on("message", this.onMessage);

        await this.onConnect();

        this.client.on("connect", this.onConnect);

        this.republishRetainedTimer = setTimeout(async () => {
            // Republish retained messages in case MQTT broker does not persist them.
            // https://github.com/Koenkk/zigbee2mqtt/issues/9629
            for (const msg of Object.values(this.retainedMessages)) {
                await this.publish(msg.topic, msg.payload, msg.options);
            }
        }, 2000);

        // Set timer at interval to check if connected to MQTT server.
        this.connectionTimer = setInterval(() => {
            if (!this.isConnected()) {
                logger.error("Not connected to MQTT server!");
            }
        }, utils.seconds(10));
    }

    async disconnect(): Promise<void> {
        clearTimeout(this.connectionTimer);
        clearTimeout(this.republishRetainedTimer);

        const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "offline"};

        // prevent undesirable error when receiving SIGTERM/SIGINT during startup
        if (this.client) {
            await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true}});
        }

        this.eventBus.removeListeners(this);
        logger.info("Disconnecting from MQTT server");
        await this.client?.endAsync();
    }

    async subscribe(topic: string): Promise<void> {
        await this.client.subscribeAsync(topic);
    }

    async unsubscribe(topic: string): Promise<void> {
        await this.client.unsubscribeAsync(topic);
    }

    @bind private async onConnect(): Promise<void> {
        logger.info("Connected to MQTT server");

        const stateData: Zigbee2MQTTAPI["bridge/state"] = {state: "online"};

        await this.publish("bridge/state", JSON.stringify(stateData), {clientOptions: {retain: true, qos: 1}});
        await this.subscribe(`${settings.get().mqtt.base_topic}/#`);
    }

    @bind public onMessage(topic: string, message: Buffer): void {
        // Since we subscribe to zigbee2mqtt/# we also receive the message we send ourselves, skip these.
        if (!this.publishedTopics.has(topic)) {
            logger.debug(() => `Received MQTT message on '${topic}' with data '${message.toString()}'`, NS);
            this.eventBus.emitMQTTMessage({topic, message: message.toString()});
        }

        if (this.republishRetainedTimer && topic === `${settings.get().mqtt.base_topic}/bridge/info`) {
            clearTimeout(this.republishRetainedTimer);

            this.republishRetainedTimer = undefined;
        }
    }

    isConnected(): boolean {
        return this.client && !this.client.reconnecting && !this.client.disconnecting && !this.client.disconnected;
    }

    async publish(topic: string, payload: string, options: Partial<MqttPublishOptions> = {}): Promise<void> {
        // TODO: add `options.validateTopic: boolean` to bypass these checks when topic is "controlled"
        if (topic.includes("+") || topic.includes("#")) {
            // https://github.com/Koenkk/zigbee2mqtt/issues/26939#issuecomment-2772309646
            logger.error(`Topic '${topic}' includes wildcard characters, skipping publish.`);
            return;
        }

        const finalOptions = {...this.defaultPublishOptions, ...options};
        topic = `${finalOptions.baseTopic}/${topic}`;

        if (finalOptions.skipReceive) {
            this.publishedTopics.add(topic);
        }

        if (finalOptions.clientOptions.retain) {
            if (payload) {
                this.retainedMessages[topic] = {payload, options: finalOptions, topic: topic.substring(finalOptions.baseTopic.length + 1)};
            } else {
                delete this.retainedMessages[topic];
            }
        }

        this.eventBus.emitMQTTMessagePublished({topic, payload, options: finalOptions});

        if (!this.isConnected()) {
            if (!finalOptions.skipLog) {
                logger.error("Not connected to MQTT server!");
                logger.error(`Cannot send message: topic: '${topic}', payload: '${payload}`);
            }
            return;
        }

        let clientOptions: IClientPublishOptions = finalOptions.clientOptions;
        if (settings.get().mqtt.force_disable_retain) {
            clientOptions = {...finalOptions.clientOptions, retain: false};
        }

        if (!finalOptions.skipLog) {
            logger.info(() => `MQTT publish: topic '${topic}', payload '${payload}'`, NS);
        }

        try {
            await this.client.publishAsync(topic, payload, clientOptions);
        } catch (error) {
            if (!finalOptions.skipLog) {
                logger.error(`MQTT server error: ${(error as Error).message}`);
                logger.error(`Could not send message: topic: '${topic}', payload: '${payload}`);
            }
        }
    }
}
