import dgram from 'node:dgram';
import { randomUUID } from 'node:crypto';
import { WebSocket } from 'ws';
import { normalizeIp } from './config.js';

const PORT_ANNOUNCEMENT = Buffer.from([0xff, 0xff, 0xff, 0xff, 0x70, 0x6f, 0x72, 0x74]);
const CONNECTIONLESS_HEADER = Buffer.from([0xff, 0xff, 0xff, 0xff]);

function isPortAnnouncement(buffer) {
  return buffer.length === 10 && buffer.subarray(0, 8).equals(PORT_ANNOUNCEMENT);
}

function isInBandPacket(buffer) {
  return buffer.length >= CONNECTIONLESS_HEADER.length
    && !buffer.subarray(0, CONNECTIONLESS_HEADER.length).equals(CONNECTIONLESS_HEADER);
}

function peerIp(req, trustedProxies) {
  const remote = normalizeIp(req.socket.remoteAddress) || 'unknown';
  if (!trustedProxies.has(remote)) return remote;
  const forwarded = String(req.headers?.['x-forwarded-for'] || '').split(',', 1)[0].trim();
  return normalizeIp(forwarded) || remote;
}

function isObject(value) {
  return value !== null && typeof value === 'object' && !Array.isArray(value);
}

export class UdpBridge {
  constructor(config, log = console) {
    this.config = config;
    this.log = log;
    this.clients = new Map();
  }

  ipFor(req) {
    return peerIp(req, this.config.trustedProxies);
  }

  canAccept(req) {
    if (this.clients.size >= this.config.maxClients) return { ok: false, reason: 'server full' };
    const ip = this.ipFor(req);
    let count = 0;
    for (const client of this.clients.values()) if (client.ip === ip) count += 1;
    if (count >= this.config.maxClientsPerIp) return { ok: false, reason: 'too many connections' };
    return { ok: true, ip };
  }

  attach(ws, req, port, room = String(port)) {
    const canonicalRoom = String(port);
    if (room !== canonicalRoom) {
      try { ws.close(1008, 'invalid room'); } catch { try { ws.terminate(); } catch {} }
      return null;
    }
    const id = randomUUID();
    const udp = dgram.createSocket(this.config.udpHost === '::1' ? 'udp6' : 'udp4');
    const now = Date.now();
    const client = {
      id,
      ip: this.ipFor(req),
      port,
      room: canonicalRoom,
      ws,
      udp,
      connectedAt: now,
      lastActivity: now,
      rxBytes: 0,
      txBytes: 0,
      rxFrames: 0,
      txFrames: 0,
      closed: false,
      ready: false,
      pending: [],
      rateStartedAt: now,
      rateFrames: 0,
      rateBytes: 0,
      voiceRateStartedAt: now,
      voiceRateFrames: 0,
      voiceRateBytes: 0,
      voiceRxFrames: 0,
      voiceTxFrames: 0,
      voiceEligible: false,
      gotPong: true,
    };
    this.clients.set(id, client);
    ws.binaryType = 'nodebuffer';

    const cleanup = (reason, code = 1000) => {
      if (client.closed) return;
      const voicePeers = this.voicePeers(client);
      client.closed = true;
      clearTimeout(client.idleTimer);
      clearInterval(client.pingTimer);
      client.pending.length = 0;
      this.clients.delete(id);
      this.announceVoicePeer(client, 'left', voicePeers);
      try { udp.close(); } catch {}
      if (ws.readyState === WebSocket.OPEN) {
        try { ws.close(code, String(reason).slice(0, 100)); } catch {}
      } else if (ws.readyState !== WebSocket.CLOSED) {
        try { ws.terminate(); } catch {}
      }
    };

    const touch = () => {
      client.lastActivity = Date.now();
      clearTimeout(client.idleTimer);
      client.idleTimer = setTimeout(() => cleanup('idle timeout', 1001), this.config.idleMs);
      client.idleTimer.unref?.();
    };

    const rateAllowed = (bytes) => {
      const stamp = Date.now();
      if (stamp - client.rateStartedAt >= 1_000) {
        client.rateStartedAt = stamp;
        client.rateFrames = 0;
        client.rateBytes = 0;
      }
      client.rateFrames += 1;
      client.rateBytes += bytes;
      return client.rateFrames <= this.config.maxFramesPerSecond && client.rateBytes <= this.config.maxBytesPerSecond;
    };

    const voiceRateAllowed = (bytes) => {
      const stamp = Date.now();
      if (stamp - client.voiceRateStartedAt >= 1_000) {
        client.voiceRateStartedAt = stamp;
        client.voiceRateFrames = 0;
        client.voiceRateBytes = 0;
      }
      client.voiceRateFrames += 1;
      client.voiceRateBytes += bytes;
      return client.voiceRateFrames <= this.config.voiceMaxMessagesPerSecond
        && client.voiceRateBytes <= this.config.voiceMaxBytesPerSecond;
    };

    const handleVoiceSignal = (data) => {
      const encoded = Buffer.isBuffer(data) ? data : Buffer.from(data);
      if (encoded.length > this.config.voiceMaxMessageBytes) return cleanup('voice signal too large', 1009);
      if (!voiceRateAllowed(encoded.length)) return cleanup('voice rate limit', 1008);

      let message;
      try { message = JSON.parse(encoded.toString('utf8')); } catch { return cleanup('invalid JSON', 1007); }
      if (!isObject(message) || message.t !== 'voice-signal') return cleanup('unsupported text frame', 1003);
      if (!isObject(message.payload)) return cleanup('invalid voice signal', 1007);
      if (message.to !== undefined && (typeof message.to !== 'string' || message.to.length < 1 || message.to.length > 128)) {
        return cleanup('invalid voice target', 1007);
      }
      if (!client.voiceEligible) return cleanup('voice not eligible', 1008);

      client.voiceRxFrames += 1;
      touch();
      this.relayVoiceSignal(client, message.to, message.payload);
    };

    udp.on('error', () => cleanup('UDP error', 1011));
    udp.on('message', (message) => {
      if (client.closed || message.length > this.config.maxFrameBytes) return;
      if (!client.voiceEligible && client.port === this.config.gamePort && isInBandPacket(message)) {
        client.voiceEligible = true;
        const peers = this.voicePeers(client);
        this.sendVoiceText(client, {
          t: 'voice-session',
          selfId: client.id,
          peerIds: peers.map((peer) => peer.id),
          room: client.room,
          eligible: true,
        });
        this.announceVoicePeer(client, 'joined', peers);
      }
      client.txBytes += message.length;
      client.txFrames += 1;
      touch();
      if (ws.readyState === WebSocket.OPEN) ws.send(message, { binary: true }, (error) => {
        if (error) cleanup('WebSocket send error', 1011);
      });
    });
    udp.bind(0, this.config.udpHost, () => {
      if (client.closed) return;
      udp.connect(port, this.config.udpHost, () => {
        if (client.closed) return;
        client.ready = true;
        for (const message of client.pending) udp.send(message);
        client.pending.length = 0;
      });
    });

    ws.on('message', (data, isBinary) => {
      if (client.closed) return;
      if (!isBinary) return handleVoiceSignal(data);
      const message = Buffer.isBuffer(data) ? data : Buffer.from(data);
      if (message.length > this.config.maxFrameBytes) return cleanup('frame too large', 1009);
      if (!rateAllowed(message.length)) return cleanup('rate limit', 1008);
      if (isPortAnnouncement(message)) {
        touch();
        return;
      }
      client.rxBytes += message.length;
      client.rxFrames += 1;
      touch();
      if (client.ready) udp.send(message);
      else if (client.pending.length < this.config.pendingDatagrams) client.pending.push(Buffer.from(message));
      else cleanup('UDP queue full', 1008);
    });
    ws.on('pong', () => {
      client.gotPong = true;
      touch();
    });
    ws.on('close', () => cleanup('closed'));
    ws.on('error', () => cleanup('WebSocket error', 1011));

    client.pingTimer = setInterval(() => {
      if (client.closed || ws.readyState !== WebSocket.OPEN) return;
      if (!client.gotPong) return cleanup('ping timeout', 1001);
      client.gotPong = false;
      try { ws.ping(); } catch { cleanup('ping error', 1011); }
    }, this.config.pingMs);
    client.pingTimer.unref?.();
    touch();
    return client;
  }

  voicePeers(client) {
    if (client.closed || !client.voiceEligible || client.port !== this.config.gamePort) return [];
    return [...this.clients.values()].filter((candidate) => candidate !== client
      && !candidate.closed
      && candidate.voiceEligible
      && candidate.port === this.config.gamePort
      && candidate.room === String(this.config.gamePort));
  }

  sendVoiceText(client, message) {
    if (client.closed || client.ws.readyState !== WebSocket.OPEN) return false;
    try {
      client.ws.send(JSON.stringify(message), { binary: false });
      return true;
    } catch {
      return false;
    }
  }

  announceVoicePeer(client, state, peers = this.voicePeers(client)) {
    for (const peer of peers) this.sendVoiceText(peer, { t: 'voice-peer', id: client.id, state });
  }

  relayVoiceSignal(sender, to, payload) {
    if (!sender.voiceEligible || sender.port !== this.config.gamePort) return;
    const peers = this.voicePeers(sender);
    const recipients = to === undefined ? peers : peers.filter((peer) => peer.id === to);
    for (const peer of recipients) {
      if (this.sendVoiceText(peer, { t: 'voice-signal', from: sender.id, payload })) {
        peer.voiceTxFrames += 1;
      }
    }
  }

  publicClients() {
    return [...this.clients.values()].map((client) => ({
      id: client.id,
      port: client.port,
      room: client.room,
      connectedAt: new Date(client.connectedAt).toISOString(),
      lastActivity: new Date(client.lastActivity).toISOString(),
      rxBytes: client.rxBytes,
      txBytes: client.txBytes,
      rxFrames: client.rxFrames,
      txFrames: client.txFrames,
      voiceRxFrames: client.voiceRxFrames,
      voiceTxFrames: client.voiceTxFrames,
      voiceEligible: client.voiceEligible,
    }));
  }

  closeAll() {
    for (const client of [...this.clients.values()]) {
      try { client.ws.close(1012, 'service restart'); } catch {}
      try { client.ws.terminate(); } catch {}
      try { client.udp.close(); } catch {}
      clearTimeout(client.idleTimer);
      clearInterval(client.pingTimer);
      client.closed = true;
      this.clients.delete(client.id);
    }
  }
}
