import * as plugins from '../../plugins.js';
import type { IZwaveJsConfig, IZwaveJsServerCommand, IZwaveJsServerEvent, IZwaveJsSnapshot, IZwaveJsState, IZwaveJsVersion } from './zwave_js.types.js';
import { ZwaveJsMapper } from './zwave_js.mapper.js';

type TEventHandler = (eventArg: IZwaveJsServerEvent) => void;

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

export class ZwaveJsClient {
  private socket?: any;
  private started = false;
  private version?: IZwaveJsVersion;
  private state?: IZwaveJsState;
  private readonly events: IZwaveJsServerEvent[] = [];
  private readonly pendingRequests = new Map<string, IPendingRequest>();
  private readonly eventHandlers = new Set<TEventHandler>();

  constructor(private readonly config: IZwaveJsConfig) {
    this.version = config.version;
    this.state = config.state || { controller: config.controller, nodes: config.nodes || [] };
  }

  public async getSnapshot(): Promise<IZwaveJsSnapshot> {
    if (this.config.url && !this.started) {
      await this.start();
    }
    return {
      ...ZwaveJsMapper.toSnapshot({ ...this.config, version: this.version, state: this.state }, Boolean(this.socket && this.socket.readyState === 1), this.events),
      url: this.config.url,
    };
  }

  public async start(): Promise<void> {
    if (this.started || !this.config.url) {
      this.started = true;
      return;
    }
    const WebSocketCtor = (globalThis as typeof globalThis & { WebSocket?: new (urlArg: string) => any }).WebSocket;
    if (!WebSocketCtor) {
      throw new Error('Global WebSocket is not available in this runtime.');
    }
    const socket = new WebSocketCtor(this.config.url);
    this.socket = socket;
    socket.addEventListener('message', (eventArg: { data: unknown }) => this.handleMessage(eventArg.data));
    socket.addEventListener('close', () => this.rejectAll(new Error('Z-Wave JS WebSocket closed.')));
    await new Promise<void>((resolve, reject) => {
      socket.addEventListener('open', () => resolve(), { once: true });
      socket.addEventListener('error', () => reject(new Error(`Unable to connect to Z-Wave JS Server at ${this.config.url}.`)), { once: true });
    });
    await this.waitForVersion();
    await this.sendCommand({ command: 'initialize', schemaVersion: this.config.schemaVersion ?? this.version?.maxSchemaVersion ?? 0, additionalUserAgentComponents: { 'smarthome.exchange': '0.1.0' } }).catch(async () => {
      await this.sendCommand({ command: 'set_api_schema', schemaVersion: this.config.schemaVersion ?? this.version?.maxSchemaVersion ?? 0 });
    });
    const result = await this.sendCommand({ command: 'start_listening' });
    if (this.isRecord(result) && this.isRecord(result.state)) {
      this.state = result.state as IZwaveJsState;
    }
    this.started = true;
  }

  public async sendCommand(commandArg: IZwaveJsServerCommand): Promise<unknown> {
    await this.ensureStarted();
    if (!this.socket || this.socket.readyState !== 1) {
      throw new Error('Z-Wave JS WebSocket is not connected.');
    }
    const messageId = `shx-${plugins.crypto.randomBytes(6).toString('hex')}`;
    const payload = { ...commandArg, messageId };
    const promise = new Promise<unknown>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingRequests.delete(messageId);
        reject(new Error(`Z-Wave JS command ${commandArg.command} timed out.`));
      }, 10000);
      this.pendingRequests.set(messageId, { resolve, reject, timer });
    });
    this.socket.send(JSON.stringify(payload));
    return promise;
  }

  public onEvent(handlerArg: TEventHandler): () => void {
    this.eventHandlers.add(handlerArg);
    return () => this.eventHandlers.delete(handlerArg);
  }

  public async destroy(): Promise<void> {
    this.rejectAll(new Error('Z-Wave JS client destroyed.'));
    if (this.socket?.readyState === 1) {
      this.socket.close();
    }
    this.socket = undefined;
    this.started = false;
    this.eventHandlers.clear();
  }

  private async ensureStarted(): Promise<void> {
    if (!this.started) {
      await this.start();
    }
  }

  private async waitForVersion(): Promise<void> {
    if (this.version) {
      return;
    }
    await new Promise<void>((resolve) => setTimeout(resolve, 100));
  }

  private handleMessage(dataArg: unknown): void {
    const text = typeof dataArg === 'string' ? dataArg : dataArg instanceof Buffer ? dataArg.toString('utf8') : String(dataArg);
    const message = JSON.parse(text) as Record<string, unknown>;
    if (message.type === 'version') {
      this.version = message as IZwaveJsVersion;
      return;
    }
    if (message.type === 'result') {
      this.handleResult(message);
      return;
    }
    if (message.type === 'event' && this.isRecord(message.event)) {
      const event = message.event as IZwaveJsServerEvent;
      this.events.push(event);
      this.applyEvent(event);
      for (const handler of this.eventHandlers) {
        handler(event);
      }
    }
  }

  private handleResult(messageArg: Record<string, unknown>): void {
    const messageId = typeof messageArg.messageId === 'string' ? messageArg.messageId : undefined;
    if (!messageId) {
      return;
    }
    const pending = this.pendingRequests.get(messageId);
    if (!pending) {
      return;
    }
    clearTimeout(pending.timer);
    this.pendingRequests.delete(messageId);
    if (messageArg.success === false) {
      pending.reject(new Error(`Z-Wave JS command failed: ${JSON.stringify(messageArg.error || messageArg)}`));
      return;
    }
    pending.resolve(messageArg.result);
  }

  private applyEvent(eventArg: IZwaveJsServerEvent): void {
    if (eventArg.source !== 'node' || typeof eventArg.nodeId !== 'number') {
      return;
    }
    const nodes = this.state?.nodes || [];
    const node = nodes.find((nodeArg) => nodeArg.nodeId === eventArg.nodeId);
    if (!node) {
      return;
    }
    if (eventArg.event === 'value updated' && eventArg.valueId) {
      const value = ZwaveJsMapper.nodeValues(node).find((valueArg) => this.valueIdMatches(valueArg, eventArg.valueId!));
      if (value) {
        value.value = eventArg.newValue ?? eventArg.value;
      }
    }
    if (eventArg.event === 'node status' && typeof eventArg.status === 'string') {
      node.status = eventArg.status;
    }
  }

  private valueIdMatches(valueArg: ReturnType<typeof ZwaveJsMapper.nodeValues>[number], valueIdArg: NonNullable<IZwaveJsServerEvent['valueId']>): boolean {
    return (valueArg.commandClass || 0) === valueIdArg.commandClass
      && (valueArg.endpoint || 0) === (valueIdArg.endpoint || 0)
      && String(valueArg.property ?? valueArg.propertyName ?? '') === String(valueIdArg.property)
      && String(valueArg.propertyKey ?? '') === String(valueIdArg.propertyKey ?? '');
  }

  private rejectAll(errorArg: Error): void {
    for (const [messageId, pending] of this.pendingRequests) {
      clearTimeout(pending.timer);
      pending.reject(errorArg);
      this.pendingRequests.delete(messageId);
    }
  }

  private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
    return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
  }
}
