import tls from 'node:tls';
import crypto from 'node:crypto';
import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from 'node:zlib';
import { EventEmitter } from 'node:events';

export class SimpleWebSocketClient extends EventEmitter {
  constructor(urlString, { headers = {} } = {}) {
    super();
    this.url = new URL(urlString);
    this.headers = headers;
    this.socket = null;
    this.buffer = Buffer.alloc(0);
    this.handshakeComplete = false;
    this.fragmentedOpcode = null;
    this.fragmentedPayloads = [];
  }

  connect() {
    const port = Number(this.url.port || 443);
    const key = crypto.randomBytes(16).toString('base64');
    this.emit('connectAttempt', {
      host: this.url.hostname,
      port,
      clientLibrary: 'SimpleWebSocketClient(node:tls)',
      binaryType: 'buffer',
      rejectUnauthorized: true
    });

    this.socket = tls.connect({
      host: this.url.hostname,
      port,
      servername: this.url.hostname,
      family: 4
    });
    this.socket.setTimeout(10000, () => {
      this.emit('error', new Error('TLS websocket connection timed out before handshake completed.'));
      this.socket?.destroy();
    });

    this.socket.once('secureConnect', () => {
      this.socket?.setTimeout(0);
      this.emit('tlsSecureConnect', {
        authorized: this.socket.authorized,
        authorizationError: this.socket.authorizationError || null,
        protocol: this.socket.getProtocol?.() || null
      });
      const path = `${this.url.pathname}${this.url.search}`;
      const requestHeaders = {
        Host: this.url.host,
        Upgrade: 'websocket',
        Connection: 'Upgrade',
        'Sec-WebSocket-Key': key,
        'Sec-WebSocket-Version': '13',
        ...this.headers
      };
      const request = [
        `GET ${path} HTTP/1.1`,
        ...Object.entries(requestHeaders).map(([name, value]) => `${name}: ${value}`),
        '',
        ''
      ].join('\r\n');
      this.socket.write(request);
      this.emit('handshakeSent', { host: this.url.host, path, headerNames: Object.keys(requestHeaders) });
    });

    this.socket.on('data', chunk => this.handleData(chunk));
    this.socket.on('error', error => this.emit('error', Object.assign(error, {
      tlsAuthorizationError: this.socket?.authorizationError || null,
      tlsAuthorized: this.socket?.authorized ?? null
    })));
    this.socket.on('close', hadError => this.emit('close', { code: hadError ? 1006 : 1000, reason: hadError ? 'transport error' : '' }));
  }

  send(text) {
    if (!this.socket || !this.handshakeComplete) {
      throw new Error('WebSocket is not open.');
    }
    this.socket.write(encodeClientFrame(String(text)));
  }

  close(code = 1000, reason = '') {
    if (!this.socket) return;
    const reasonBuffer = Buffer.from(reason);
    const payload = Buffer.alloc(2 + reasonBuffer.length);
    payload.writeUInt16BE(code, 0);
    reasonBuffer.copy(payload, 2);
    this.socket.write(encodeClientFrame(payload, 0x8));
    this.socket.end();
  }

  handleData(chunk) {
    this.buffer = Buffer.concat([this.buffer, chunk]);
    if (!this.handshakeComplete) {
      const headerEnd = this.buffer.indexOf('\r\n\r\n');
      if (headerEnd === -1) return;

      const headerText = this.buffer.subarray(0, headerEnd).toString('utf8');
      const lines = headerText.split('\r\n');
      const statusLine = lines.shift() || '';
      const statusMatch = statusLine.match(/^HTTP\/1\.1\s+(\d+)/);
      const statusCode = statusMatch ? Number(statusMatch[1]) : 0;
      const headers = Object.fromEntries(lines.map(line => {
        const separator = line.indexOf(':');
        return separator === -1
          ? [line.toLowerCase(), '']
          : [line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim()];
      }));

      this.buffer = this.buffer.subarray(headerEnd + 4);
      if (statusCode !== 101) {
        this.emit('unexpectedResponse', { statusCode, statusLine, headers });
        this.socket?.destroy();
        return;
      }

      this.handshakeComplete = true;
      this.emit('open', { statusCode, headers });
    }

    this.readFrames();
  }

  readFrames() {
    while (this.buffer.length >= 2) {
      const first = this.buffer[0];
      const second = this.buffer[1];
      const opcode = first & 0x0f;
      const finalFrame = (first & 0x80) !== 0;
      const rsv1 = (first & 0x40) !== 0;
      const masked = (second & 0x80) !== 0;
      let length = second & 0x7f;
      let offset = 2;

      if (length === 126) {
        if (this.buffer.length < offset + 2) return;
        length = this.buffer.readUInt16BE(offset);
        offset += 2;
      } else if (length === 127) {
        if (this.buffer.length < offset + 8) return;
        const high = this.buffer.readUInt32BE(offset);
        const low = this.buffer.readUInt32BE(offset + 4);
        length = high * 2 ** 32 + low;
        offset += 8;
      }

      let mask;
      if (masked) {
        if (this.buffer.length < offset + 4) return;
        mask = this.buffer.subarray(offset, offset + 4);
        offset += 4;
      }

      if (this.buffer.length < offset + length) return;

      let payload = this.buffer.subarray(offset, offset + length);
      this.buffer = this.buffer.subarray(offset + length);

      if (masked && mask) {
        payload = Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4]));
      }

      this.emit('frame', {
        opcode,
        finalFrame,
        rsv1,
        masked,
        byteLength: payload.length,
        frameType: frameType(opcode),
        compressed: rsv1
      });

      if (opcode === 0x0) {
        if (this.fragmentedOpcode === null) continue;
        this.fragmentedPayloads.push(payload);
        if (!finalFrame) continue;
        const completePayload = Buffer.concat(this.fragmentedPayloads);
        const completeOpcode = this.fragmentedOpcode;
        this.fragmentedOpcode = null;
        this.fragmentedPayloads = [];
        this.emitMessageFrame(completeOpcode, completePayload, { fragmented: true, compressed: rsv1 });
      } else if (opcode === 0x1 || opcode === 0x2) {
        if (!finalFrame) {
          this.fragmentedOpcode = opcode;
          this.fragmentedPayloads = [payload];
          continue;
        }
        this.emitMessageFrame(opcode, payload, { fragmented: false, compressed: rsv1 });
      } else if (opcode === 0x8) {
        const code = payload.length >= 2 ? payload.readUInt16BE(0) : 1000;
        const reason = payload.length > 2 ? payload.subarray(2).toString('utf8') : '';
        this.emit('close', { code, reason });
        this.socket?.end();
      } else if (opcode === 0x9) {
        this.socket?.write(encodeClientFrame(payload, 0xA));
      }
    }
  }

  emitMessageFrame(opcode, payload, metadata = {}) {
    if (opcode === 0x1) {
      this.emit('message', payload.toString('utf8'), {
        frameType: 'text',
        byteLength: payload.length,
        ...metadata
      });
      return;
    }

    const decoded = decodeBinaryPayload(payload);
    this.emit('message', decoded.value, {
      frameType: 'binary',
      byteLength: payload.length,
      decodedAs: decoded.decodedAs,
      decodeError: decoded.error,
      ...metadata
    });
  }
}

function decodeBinaryPayload(payload) {
  for (const [name, decode] of [
    ['utf8', buffer => buffer.toString('utf8')],
    ['gzip', buffer => gunzipSync(buffer).toString('utf8')],
    ['deflate', buffer => inflateSync(buffer).toString('utf8')],
    ['deflate_raw', buffer => inflateRawSync(buffer).toString('utf8')],
    ['brotli', buffer => brotliDecompressSync(buffer).toString('utf8')]
  ]) {
    try {
      const value = decode(payload);
      if (looksTextLike(value)) return { value, decodedAs: name, error: null };
    } catch {}
  }
  return { value: payload, decodedAs: 'binary', error: 'No text or compressed JSON decoder matched.' };
}

function looksTextLike(value) {
  const text = String(value || '').trim();
  if (!text) return false;
  return text.startsWith('{') || text.startsWith('[') || text.includes('type') || text.includes('topic');
}

function frameType(opcode) {
  if (opcode === 0x0) return 'continuation';
  if (opcode === 0x1) return 'text';
  if (opcode === 0x2) return 'binary';
  if (opcode === 0x8) return 'close';
  if (opcode === 0x9) return 'ping';
  if (opcode === 0xA) return 'pong';
  return `opcode_${opcode}`;
}

function encodeClientFrame(input, opcode = 0x1) {
  const payload = Buffer.isBuffer(input) ? input : Buffer.from(input);
  const mask = crypto.randomBytes(4);
  let header;

  if (payload.length < 126) {
    header = Buffer.alloc(2);
    header[1] = 0x80 | payload.length;
  } else if (payload.length < 65536) {
    header = Buffer.alloc(4);
    header[1] = 0x80 | 126;
    header.writeUInt16BE(payload.length, 2);
  } else {
    header = Buffer.alloc(10);
    header[1] = 0x80 | 127;
    header.writeUInt32BE(0, 2);
    header.writeUInt32BE(payload.length, 6);
  }

  header[0] = 0x80 | opcode;
  const maskedPayload = Buffer.from(payload.map((byte, index) => byte ^ mask[index % 4]));
  return Buffer.concat([header, mask, maskedPayload]);
}
