import * as plugins from '../../plugins.js';
import type { IMqttConfig, IMqttMessage, IMqttPayload, IMqttPublishOptions, IMqttSnapshot } from './mqtt.types.js';
import { MqttMapper } from './mqtt.mapper.js';

const packetConnect = 1;
const packetConnack = 2;
const packetPublish = 3;
const packetPuback = 4;
const packetSubscribe = 8;
const packetSuback = 9;
const packetPingreq = 12;
const packetPingresp = 13;
const packetDisconnect = 14;
const defaultDiscoveryPrefix = 'homeassistant';

type TMqttMessageHandler = (messageArg: IMqttMessage) => void;

interface IPendingPacket {
  resolve(valueArg?: unknown): void;
  reject(errorArg: Error): void;
  timer: ReturnType<typeof setTimeout>;
}

export class MqttClient {
  private connection?: MqttTcpConnection;
  private readonly receivedMessages: IMqttMessage[] = [];
  private readonly messageHandlers = new Set<TMqttMessageHandler>();
  private started = false;

  constructor(private readonly config: IMqttConfig) {}

  public async getSnapshot(): Promise<IMqttSnapshot> {
    if (this.config.host && !this.started) {
      await this.start();
    }
    return MqttMapper.toSnapshot(this.config, Boolean(this.connection?.connected), this.receivedMessages);
  }

  public async start(): Promise<void> {
    if (this.started || !this.config.host) {
      this.started = true;
      return;
    }
    const connection = new MqttTcpConnection(this.config, (messageArg) => this.handleMessage(messageArg));
    await connection.connect();
    this.connection = connection;
    this.started = true;

    if (this.config.birthMessage !== false) {
      const birth = this.config.birthMessage || { topic: `${this.config.discoveryPrefix || defaultDiscoveryPrefix}/status`, payload: 'online', qos: 0, retain: false };
      await this.publish(birth);
    }

    const subscriptions = new Set(this.config.subscriptions || []);
    if (this.config.discovery !== false) {
      subscriptions.add(`${this.config.discoveryPrefix || defaultDiscoveryPrefix}/#`);
    }
    for (const topic of subscriptions) {
      await this.subscribe(topic);
    }
  }

  public async publish(optionsArg: IMqttPublishOptions): Promise<void> {
    await this.ensureConnection();
    await this.connection?.publish(optionsArg);
  }

  public async subscribe(topicArg: string, handlerArg?: TMqttMessageHandler): Promise<() => Promise<void>> {
    if (handlerArg) {
      this.messageHandlers.add(handlerArg);
    }
    await this.ensureConnection();
    await this.connection?.subscribe(topicArg);
    return async () => {
      if (handlerArg) {
        this.messageHandlers.delete(handlerArg);
      }
    };
  }

  public async destroy(): Promise<void> {
    if (this.config.birthMessage !== false && this.connection?.connected) {
      await this.publish({ topic: `${this.config.discoveryPrefix || defaultDiscoveryPrefix}/status`, payload: 'offline', qos: 0, retain: false });
    }
    this.connection?.destroy();
    this.connection = undefined;
    this.started = false;
    this.messageHandlers.clear();
  }

  private async ensureConnection(): Promise<void> {
    if (!this.connection?.connected) {
      await this.start();
    }
    if (!this.connection?.connected) {
      throw new Error('MQTT broker connection is not available.');
    }
  }

  private handleMessage(messageArg: IMqttMessage): void {
    this.receivedMessages.push(messageArg);
    for (const handler of this.messageHandlers) {
      handler(messageArg);
    }
  }
}

class MqttTcpConnection {
  private socket?: plugins.net.Socket;
  private buffer = Buffer.alloc(0);
  private nextPacketId = 1;
  private pingTimer?: ReturnType<typeof setInterval>;
  private connectPending?: IPendingPacket;
  private readonly pendingPackets = new Map<number, IPendingPacket>();
  private readonly subscriptions = new Set<string>();
  public connected = false;

  constructor(private readonly config: IMqttConfig, private readonly messageHandler: TMqttMessageHandler) {}

  public async connect(): Promise<void> {
    if (this.connected) {
      return;
    }
    if (!this.config.host) {
      throw new Error('MQTT host is required when fixture data is not provided.');
    }
    this.socket = await this.createSocket();
    this.socket.on('data', (chunkArg) => this.handleData(chunkArg));
    this.socket.on('error', (errorArg) => this.rejectAll(errorArg));
    this.socket.on('close', () => {
      this.connected = false;
      this.rejectAll(new Error('MQTT socket closed.'));
    });
    await this.sendConnect();
    const keepalive = this.config.keepalive || 60;
    this.pingTimer = setInterval(() => {
      if (this.connected) {
        void this.writePacket(packetPingreq, Buffer.alloc(0));
      }
    }, Math.max(15, keepalive) * 1000);
  }

  public async publish(optionsArg: IMqttPublishOptions): Promise<void> {
    const topic = this.encodeString(optionsArg.topic);
    const payload = this.payloadBuffer(optionsArg.payload);
    const qos = optionsArg.qos || 0;
    if (qos !== 0) {
      throw new Error('MQTT publish currently supports QoS 0.');
    }
    await this.writePacket(packetPublish, Buffer.concat([topic, payload]), optionsArg.retain ? 0x01 : 0);
  }

  public async subscribe(topicArg: string): Promise<void> {
    if (this.subscriptions.has(topicArg)) {
      return;
    }
    const packetId = this.nextId();
    const variableHeader = Buffer.alloc(2);
    variableHeader.writeUInt16BE(packetId, 0);
    const payload = Buffer.concat([this.encodeString(topicArg), Buffer.from([0])]);
    const promise = this.waitForPacket(packetId, `MQTT subscribe ${topicArg}`);
    await this.writePacket(packetSubscribe, Buffer.concat([variableHeader, payload]), 0x02);
    await promise;
    this.subscriptions.add(topicArg);
  }

  public destroy(): void {
    if (this.connected) {
      void this.writePacket(packetDisconnect, Buffer.alloc(0));
    }
    if (this.pingTimer) {
      clearInterval(this.pingTimer);
      this.pingTimer = undefined;
    }
    this.rejectAll(new Error('MQTT connection destroyed.'));
    this.socket?.destroy();
    this.socket = undefined;
    this.connected = false;
  }

  private async createSocket(): Promise<plugins.net.Socket> {
    const port = this.config.port || (this.config.protocol === 'mqtts' ? 8883 : 1883);
    if (this.config.protocol === 'mqtts') {
      return new Promise<plugins.net.Socket>((resolve, reject) => {
        const socket = plugins.tls.connect({
          host: this.config.host,
          port,
          rejectUnauthorized: this.config.rejectUnauthorized !== false,
          servername: this.config.host,
        });
        socket.once('secureConnect', () => resolve(socket));
        socket.once('error', reject);
      });
    }
    return new Promise<plugins.net.Socket>((resolve, reject) => {
      const socket = plugins.net.connect({ host: this.config.host, port });
      socket.once('connect', () => resolve(socket));
      socket.once('error', reject);
    });
  }

  private async sendConnect(): Promise<void> {
    const keepalive = this.config.keepalive || 60;
    const clientId = this.config.clientId || `smarthome-exchange-${plugins.crypto.randomBytes(4).toString('hex')}`;
    const will = this.config.willMessage === false ? undefined : this.config.willMessage || { topic: `${this.config.discoveryPrefix || defaultDiscoveryPrefix}/status`, payload: 'offline', qos: 0, retain: false };
    let flags = this.config.clean === false ? 0 : 0x02;
    if (will) {
      flags |= 0x04 | ((will.qos || 0) << 3);
      if (will.retain) {
        flags |= 0x20;
      }
    }
    if (this.config.password) {
      flags |= 0x40;
    }
    if (this.config.username) {
      flags |= 0x80;
    }
    const variableHeader = Buffer.concat([
      this.encodeString('MQTT'),
      Buffer.from([4, flags]),
      this.uint16(keepalive),
    ]);
    const payloadParts = [this.encodeString(clientId)];
    if (will) {
      payloadParts.push(this.encodeString(will.topic), this.payloadWithLength(will.payload));
    }
    if (this.config.username) {
      payloadParts.push(this.encodeString(this.config.username));
    }
    if (this.config.password) {
      payloadParts.push(this.encodeString(this.config.password));
    }
    const promise = new Promise<void>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.connectPending = undefined;
        reject(new Error('MQTT connect timed out.'));
      }, 7000);
      this.connectPending = { resolve: () => resolve(), reject, timer };
    });
    await this.writePacket(packetConnect, Buffer.concat([variableHeader, ...payloadParts]));
    await promise;
  }

  private handleData(chunkArg: Buffer | string): void {
    const chunk = Buffer.isBuffer(chunkArg) ? chunkArg : Buffer.from(chunkArg);
    this.buffer = Buffer.concat([this.buffer, chunk]);
    while (this.buffer.length >= 2) {
      const remainingLength = this.decodeRemainingLength(this.buffer, 1);
      if (!remainingLength || this.buffer.length < 1 + remainingLength.bytes + remainingLength.value) {
        return;
      }
      const header = this.buffer[0];
      const payloadStart = 1 + remainingLength.bytes;
      const payloadEnd = payloadStart + remainingLength.value;
      const payload = this.buffer.subarray(payloadStart, payloadEnd);
      this.buffer = this.buffer.subarray(payloadEnd);
      this.handlePacket(header, payload);
    }
  }

  private handlePacket(headerArg: number, payloadArg: Buffer): void {
    const packetType = headerArg >> 4;
    if (packetType === packetConnack) {
      const returnCode = payloadArg[1];
      const pending = this.connectPending;
      this.connectPending = undefined;
      if (!pending) {
        return;
      }
      clearTimeout(pending.timer);
      if (returnCode === 0) {
        this.connected = true;
        pending.resolve();
      } else {
        pending.reject(new Error(`MQTT broker rejected connection with code ${returnCode}.`));
      }
      return;
    }
    if (packetType === packetSuback) {
      const packetId = payloadArg.readUInt16BE(0);
      const pending = this.pendingPackets.get(packetId);
      if (pending) {
        clearTimeout(pending.timer);
        this.pendingPackets.delete(packetId);
        const failure = payloadArg.subarray(2).includes(0x80);
        failure ? pending.reject(new Error(`MQTT subscribe ${packetId} was rejected.`)) : pending.resolve();
      }
      return;
    }
    if (packetType === packetPublish) {
      this.handlePublish(headerArg, payloadArg);
      return;
    }
    if (packetType === packetPingresp) {
      return;
    }
  }

  private handlePublish(headerArg: number, payloadArg: Buffer): void {
    const retain = Boolean(headerArg & 0x01);
    const qos = ((headerArg >> 1) & 0x03) as 0 | 1 | 2;
    const topicLength = payloadArg.readUInt16BE(0);
    const topic = payloadArg.subarray(2, 2 + topicLength).toString('utf8');
    let offset = 2 + topicLength;
    if (qos > 0) {
      const packetId = payloadArg.readUInt16BE(offset);
      offset += 2;
      if (qos === 1) {
        void this.writePacket(packetPuback, this.uint16(packetId));
      }
    }
    const payload = payloadArg.subarray(offset).toString('utf8');
    const subscribedTopic = [...this.subscriptions].find((topicArg) => this.topicMatches(topicArg, topic)) || topic;
    this.messageHandler({ topic, payload, qos, retain, subscribedTopic, timestamp: Date.now() } as IMqttMessage & { subscribedTopic: string });
  }

  private waitForPacket(packetIdArg: number, descriptionArg: string): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingPackets.delete(packetIdArg);
        reject(new Error(`${descriptionArg} timed out.`));
      }, 7000);
      this.pendingPackets.set(packetIdArg, { resolve: () => resolve(), reject, timer });
    });
  }

  private async writePacket(packetTypeArg: number, payloadArg: Buffer, flagsArg = 0): Promise<void> {
    if (!this.socket || this.socket.destroyed) {
      throw new Error('MQTT socket is not connected.');
    }
    const fixedHeader = Buffer.concat([Buffer.from([(packetTypeArg << 4) | flagsArg]), this.encodeRemainingLength(payloadArg.length)]);
    const packet = Buffer.concat([fixedHeader, payloadArg]);
    await new Promise<void>((resolve, reject) => {
      this.socket?.write(packet, (errorArg) => errorArg ? reject(errorArg) : resolve());
    });
  }

  private encodeRemainingLength(valueArg: number): Buffer {
    const bytes: number[] = [];
    let value = valueArg;
    do {
      let encodedByte = value % 128;
      value = Math.floor(value / 128);
      if (value > 0) {
        encodedByte |= 128;
      }
      bytes.push(encodedByte);
    } while (value > 0);
    return Buffer.from(bytes);
  }

  private decodeRemainingLength(bufferArg: Buffer, offsetArg: number): { value: number; bytes: number } | undefined {
    let multiplier = 1;
    let value = 0;
    let bytes = 0;
    let offset = offsetArg;
    while (offset < bufferArg.length) {
      const encodedByte = bufferArg[offset++];
      bytes++;
      value += (encodedByte & 127) * multiplier;
      if ((encodedByte & 128) === 0) {
        return { value, bytes };
      }
      multiplier *= 128;
      if (bytes > 4) {
        throw new Error('Invalid MQTT remaining length.');
      }
    }
    return undefined;
  }

  private payloadBuffer(payloadArg: IMqttPayload | undefined): Buffer {
    if (payloadArg === undefined || payloadArg === null) {
      return Buffer.alloc(0);
    }
    if (typeof payloadArg === 'object') {
      return Buffer.from(JSON.stringify(payloadArg), 'utf8');
    }
    return Buffer.from(String(payloadArg), 'utf8');
  }

  private payloadWithLength(payloadArg: IMqttPayload | undefined): Buffer {
    const payload = this.payloadBuffer(payloadArg);
    return Buffer.concat([this.uint16(payload.length), payload]);
  }

  private encodeString(valueArg: string): Buffer {
    const value = Buffer.from(valueArg, 'utf8');
    return Buffer.concat([this.uint16(value.length), value]);
  }

  private uint16(valueArg: number): Buffer {
    const buffer = Buffer.alloc(2);
    buffer.writeUInt16BE(valueArg, 0);
    return buffer;
  }

  private nextId(): number {
    const id = this.nextPacketId++;
    if (this.nextPacketId > 0xffff) {
      this.nextPacketId = 1;
    }
    return id;
  }

  private topicMatches(filterArg: string, topicArg: string): boolean {
    const filterParts = filterArg.split('/');
    const topicParts = topicArg.split('/');
    for (let index = 0; index < filterParts.length; index++) {
      const filterPart = filterParts[index];
      if (filterPart === '#') {
        return index === filterParts.length - 1;
      }
      if (filterPart === '+') {
        continue;
      }
      if (filterPart !== topicParts[index]) {
        return false;
      }
    }
    return filterParts.length === topicParts.length;
  }

  private rejectAll(errorArg: Error): void {
    if (this.connectPending) {
      clearTimeout(this.connectPending.timer);
      this.connectPending.reject(errorArg);
      this.connectPending = undefined;
    }
    for (const [packetId, pending] of this.pendingPackets) {
      clearTimeout(pending.timer);
      pending.reject(errorArg);
      this.pendingPackets.delete(packetId);
    }
  }
}
