import * as plugins from '../../plugins.js';
import type {
  IMatterCommandFrame,
  IMatterConfig,
  IMatterErrorFrame,
  IMatterNode,
  IMatterResultFrame,
  IMatterServerCommand,
  IMatterServerEvent,
  IMatterServerInfo,
  IMatterSnapshot,
} from './matter.types.js';

const defaultMatterServerUrl = 'ws://localhost:5580/ws';
const defaultCommandTimeoutMs = 300000;
const bigIntMarker = '__BIGINT__';

type TMatterEventHandler = (eventArg: IMatterServerEvent) => void;

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

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

export class MatterClient {
  private socket?: any;
  private started = false;
  private serverInfo?: IMatterServerInfo;
  private readonly nodes = new Map<string, IMatterNode>();
  private readonly events: IMatterServerEvent[] = [];
  private readonly pendingRequests = new Map<string, IPendingRequest>();
  private readonly eventHandlers = new Set<TMatterEventHandler>();
  private pendingServerInfo?: IPendingServerInfo;
  private messageCounter = Math.floor(Math.random() * 0x7fffffff);

  constructor(private readonly config: IMatterConfig) {
    this.serverInfo = config.snapshot?.serverInfo || config.serverInfo;
    for (const node of config.snapshot?.nodes || config.nodes || []) {
      this.nodes.set(this.nodeKey(node.node_id), this.cloneNode(node));
    }
    for (const event of config.snapshot?.events || config.events || []) {
      this.events.push(event);
    }
  }

  public async getSnapshot(): Promise<IMatterSnapshot> {
    if (this.hasLocalSnapshot() && this.config.connect !== true) {
      return {
        serverInfo: this.config.snapshot?.serverInfo || this.serverInfo,
        nodes: (this.config.snapshot?.nodes || [...this.nodes.values()]).map((nodeArg) => this.cloneNode(nodeArg)),
        events: [...(this.config.snapshot?.events || this.events)],
        connected: false,
        url: this.url(),
      };
    }
    if (this.url() && !this.started) {
      await this.start();
    }
    return {
      serverInfo: this.serverInfo,
      nodes: [...this.nodes.values()].map((nodeArg) => this.cloneNode(nodeArg)),
      events: [...this.events],
      connected: Boolean(this.socket?.readyState === 1),
      url: this.url(),
    };
  }

  public async start(): Promise<void> {
    if (this.started) {
      return;
    }
    const url = this.url();
    if (!url || (this.hasLocalSnapshot() && this.config.connect !== true)) {
      this.started = true;
      return;
    }
    await this.connect(url);
    const nodes = await this.sendConnectedCommand<IMatterNode[]>({ command: 'start_listening', args: {} });
    this.nodes.clear();
    if (Array.isArray(nodes)) {
      for (const node of nodes) {
        this.nodes.set(this.nodeKey(node.node_id), this.cloneNode(node));
      }
    }
    this.started = true;
  }

  public async sendCommand<TResult = unknown>(commandArg: IMatterServerCommand): Promise<TResult> {
    await this.ensureStarted();
    return this.sendConnectedCommand<TResult>(commandArg);
  }

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

  public async destroy(): Promise<void> {
    this.rejectAll(new Error('Matter client destroyed.'));
    if (this.pendingServerInfo) {
      clearTimeout(this.pendingServerInfo.timer);
      this.pendingServerInfo.reject(new Error('Matter client destroyed.'));
      this.pendingServerInfo = undefined;
    }
    if (this.socket?.readyState === 1 || this.socket?.readyState === 0) {
      this.socket.close();
    }
    this.socket = undefined;
    this.started = false;
    this.eventHandlers.clear();
  }

  private async ensureStarted(): Promise<void> {
    if (!this.started) {
      await this.start();
    }
    if (!this.socket || this.socket.readyState !== 1) {
      throw new Error('Matter Server WebSocket is not connected.');
    }
  }

  private async connect(urlArg: string): Promise<void> {
    if (this.socket?.readyState === 1) {
      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(urlArg);
    this.socket = socket;
    this.addSocketEvent(socket, 'message', (eventArg: { data: unknown }) => this.handleMessage(eventArg.data));
    this.addSocketEvent(socket, 'close', () => this.handleClose());

    await new Promise<void>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingServerInfo = undefined;
        reject(new Error(`Matter Server at ${urlArg} did not send server info.`));
      }, 10000);
      this.pendingServerInfo = { resolve, reject, timer };
      this.addSocketEvent(socket, 'error', () => {
        if (this.pendingServerInfo) {
          clearTimeout(this.pendingServerInfo.timer);
          this.pendingServerInfo = undefined;
        }
        reject(new Error(`Unable to connect to Matter Server at ${urlArg}.`));
      }, { once: true });
    });
  }

  private sendConnectedCommand<TResult>(commandArg: IMatterServerCommand): Promise<TResult> {
    if (!this.socket || this.socket.readyState !== 1) {
      throw new Error('Matter Server WebSocket is not connected.');
    }
    if (commandArg.requireSchema && this.serverInfo && this.serverInfo.schema_version < commandArg.requireSchema) {
      throw new Error(`Matter command ${commandArg.command} requires schema ${commandArg.requireSchema}.`);
    }
    const messageId = this.nextMessageId();
    const frame: IMatterCommandFrame = {
      message_id: messageId,
      command: commandArg.command,
      args: commandArg.args || {},
    };
    const timeoutMs = commandArg.timeoutMs ?? this.config.commandTimeoutMs ?? defaultCommandTimeoutMs;
    const promise = new Promise<TResult>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingRequests.delete(messageId);
        reject(new Error(`Matter command ${commandArg.command} timed out.`));
      }, timeoutMs);
      this.pendingRequests.set(messageId, { resolve: resolve as (valueArg: unknown) => void, reject, timer });
    });
    this.socket.send(toMatterJson(frame));
    return promise;
  }

  private handleMessage(dataArg: unknown): void {
    const message = parseMatterMessage(dataArg);
    if (!this.isRecord(message)) {
      return;
    }
    if (this.isServerInfo(message) && !('message_id' in message)) {
      this.serverInfo = message;
      if (this.pendingServerInfo) {
        clearTimeout(this.pendingServerInfo.timer);
        const pending = this.pendingServerInfo;
        this.pendingServerInfo = undefined;
        pending.resolve();
      }
      return;
    }
    if (typeof message.event === 'string') {
      this.handleEvent(message as unknown as IMatterServerEvent);
      return;
    }
    if (typeof message.message_id === 'string' && 'error_code' in message) {
      this.rejectPending(message as unknown as IMatterErrorFrame);
      return;
    }
    if (typeof message.message_id === 'string' && 'result' in message) {
      this.resolvePending(message as unknown as IMatterResultFrame);
    }
  }

  private handleEvent(eventArg: IMatterServerEvent): void {
    this.events.push(eventArg);
    if ((eventArg.event === 'node_added' || eventArg.event === 'node_updated') && this.isMatterNode(eventArg.data)) {
      this.nodes.set(this.nodeKey(eventArg.data.node_id), this.cloneNode(eventArg.data));
    } else if (eventArg.event === 'node_removed') {
      this.nodes.delete(this.nodeKey(eventArg.data));
    } else if (eventArg.event === 'attribute_updated' && Array.isArray(eventArg.data)) {
      const [nodeId, attributePath, value] = eventArg.data;
      if (typeof attributePath === 'string') {
        const node = this.nodes.get(this.nodeKey(nodeId));
        if (node) {
          const updated = this.cloneNode(node);
          updated.attributes[attributePath] = value;
          this.nodes.set(this.nodeKey(nodeId), updated);
        }
      }
    } else if (eventArg.event === 'server_info_updated' && this.isServerInfo(eventArg.data)) {
      this.serverInfo = eventArg.data;
    }
    for (const handler of this.eventHandlers) {
      handler(eventArg);
    }
  }

  private resolvePending(frameArg: IMatterResultFrame): void {
    const pending = this.pendingRequests.get(frameArg.message_id);
    if (!pending) {
      return;
    }
    clearTimeout(pending.timer);
    this.pendingRequests.delete(frameArg.message_id);
    pending.resolve(frameArg.result);
  }

  private rejectPending(frameArg: IMatterErrorFrame): void {
    const pending = this.pendingRequests.get(frameArg.message_id);
    if (!pending) {
      return;
    }
    clearTimeout(pending.timer);
    this.pendingRequests.delete(frameArg.message_id);
    pending.reject(new Error(frameArg.details || `Matter command failed with error ${frameArg.error_code}.`));
  }

  private handleClose(): void {
    if (this.pendingServerInfo) {
      clearTimeout(this.pendingServerInfo.timer);
      this.pendingServerInfo.reject(new Error('Matter Server WebSocket closed before server info.'));
      this.pendingServerInfo = undefined;
    }
    this.rejectAll(new Error('Matter Server WebSocket closed.'));
    this.socket = undefined;
    this.started = false;
  }

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

  private addSocketEvent(socketArg: any, typeArg: string, handlerArg: (eventArg: any) => void, optionsArg?: { once?: boolean }): void {
    if (typeof socketArg.addEventListener === 'function') {
      socketArg.addEventListener(typeArg, handlerArg, optionsArg);
      return;
    }
    const property = `on${typeArg}`;
    const previous = socketArg[property];
    socketArg[property] = optionsArg?.once ? (eventArg: any) => {
      socketArg[property] = previous;
      handlerArg(eventArg);
    } : handlerArg;
  }

  private nextMessageId(): string {
    if (this.messageCounter >= Number.MAX_SAFE_INTEGER) {
      this.messageCounter = 0;
    }
    return `shx-${++this.messageCounter}-${plugins.crypto.randomBytes(3).toString('hex')}`;
  }

  private url(): string | undefined {
    if (this.config.url) {
      return this.config.url;
    }
    if (this.config.host) {
      return `ws://${this.config.host}:${this.config.port || 5580}/ws`;
    }
    return this.hasLocalSnapshot() ? undefined : defaultMatterServerUrl;
  }

  private hasLocalSnapshot(): boolean {
    return Boolean(this.config.snapshot || this.config.nodes?.length || this.config.serverInfo || this.config.events?.length);
  }

  private cloneNode(nodeArg: IMatterNode): IMatterNode {
    return { ...nodeArg, attributes: { ...nodeArg.attributes } };
  }

  private nodeKey(valueArg: unknown): string {
    return String(valueArg);
  }

  private isMatterNode(valueArg: unknown): valueArg is IMatterNode {
    return this.isRecord(valueArg) && 'node_id' in valueArg && this.isRecord(valueArg.attributes);
  }

  private isServerInfo(valueArg: unknown): valueArg is IMatterServerInfo {
    return this.isRecord(valueArg)
      && typeof valueArg.schema_version === 'number'
      && typeof valueArg.min_supported_schema_version === 'number'
      && typeof valueArg.sdk_version === 'string';
  }

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

function parseMatterMessage(dataArg: unknown): unknown {
  const text = typeof dataArg === 'string'
    ? dataArg
    : Buffer.isBuffer(dataArg)
      ? dataArg.toString('utf8')
      : dataArg instanceof ArrayBuffer
        ? Buffer.from(dataArg).toString('utf8')
        : String(dataArg);
  return parseBigIntAwareJson(text);
}

function toMatterJson(valueArg: unknown): string {
  const replacements: Array<{ from: string; to: string }> = [];
  let result = JSON.stringify(valueArg, (_key, value) => {
    if (typeof value === 'bigint') {
      if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER) {
        replacements.push({ from: `"0x${value.toString(16)}"`, to: value.toString() });
        return `0x${value.toString(16)}`;
      }
      return Number(value);
    }
    return value;
  });
  for (const replacement of replacements) {
    result = result.replaceAll(replacement.from, replacement.to);
  }
  return result;
}

function parseBigIntAwareJson(jsonArg: string): unknown {
  const result: string[] = [];
  let index = 0;
  let inString = false;
  while (index < jsonArg.length) {
    const char = jsonArg[index];
    if (inString) {
      if (char === '\\') {
        result.push(char);
        index++;
        if (index < jsonArg.length) {
          result.push(jsonArg[index]);
          index++;
        }
      } else if (char === '"') {
        result.push(char);
        inString = false;
        index++;
      } else {
        result.push(char);
        index++;
      }
      continue;
    }
    if (char === '"') {
      result.push(char);
      inString = true;
      index++;
      continue;
    }
    if (char >= '0' && char <= '9') {
      const hasMinus = result.length > 0 && result[result.length - 1] === '-';
      if (hasMinus) {
        result.pop();
      }
      const start = index;
      while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') {
        index++;
      }
      let isFloat = false;
      if (jsonArg[index] === '.') {
        isFloat = true;
        index++;
        while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') {
          index++;
        }
      }
      if (jsonArg[index] === 'e' || jsonArg[index] === 'E') {
        isFloat = true;
        index++;
        if (jsonArg[index] === '+' || jsonArg[index] === '-') {
          index++;
        }
        while (index < jsonArg.length && jsonArg[index] >= '0' && jsonArg[index] <= '9') {
          index++;
        }
      }
      const numberString = `${hasMinus ? '-' : ''}${jsonArg.slice(start, index)}`;
      if (!isFloat && numberString.length - (hasMinus ? 1 : 0) >= 15) {
        const value = BigInt(numberString);
        result.push(value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER ? `"${bigIntMarker}${numberString}"` : numberString);
      } else {
        result.push(numberString);
      }
      continue;
    }
    result.push(char);
    index++;
  }
  return JSON.parse(result.join(''), (_key, value) => {
    if (typeof value === 'string' && value.startsWith(bigIntMarker)) {
      return BigInt(value.slice(bigIntMarker.length));
    }
    return value;
  });
}
