//go:build dev

package bundler

// A minimal RFC 6455 WebSocket server — just enough for one-way server→browser
// push of HMR messages. We hand-roll it (rather than add a dependency) because
// the surface we need is tiny: the handshake, unmasked server text frames, and a
// read loop that answers pings and notices close. No per-message compression, no
// fragmentation, no client→server application data. All of internal/bundler's
// HMR support is behind `//go:build dev`, so prod builds compile none of it.

import (
	"bufio"
	"crypto/sha1"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"sync"
)

// wsGUID is the RFC 6455 magic value concatenated with Sec-WebSocket-Key to
// derive the accept token.
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

type hub struct {
	mu      sync.Mutex
	clients map[*wsClient]struct{}
}

func newHub() *hub { return &hub{clients: map[*wsClient]struct{}{}} }

type wsClient struct {
	conn net.Conn
	brw  *bufio.ReadWriter
	wmu  sync.Mutex // serialize frame writes across broadcast + pong
}

// ServeWS upgrades an HTTP/1.1 request to a WebSocket and registers the client.
func (h *hub) ServeWS(w http.ResponseWriter, r *http.Request) {
	key := r.Header.Get("Sec-WebSocket-Key")
	if key == "" {
		http.Error(w, "expected a WebSocket handshake", http.StatusBadRequest)
		return
	}
	hj, ok := w.(http.Hijacker)
	if !ok {
		http.Error(w, "connection does not support hijacking", http.StatusInternalServerError)
		return
	}
	conn, brw, err := hj.Hijack()
	if err != nil {
		return
	}

	accept := computeAccept(key)
	if _, err := brw.WriteString(
		"HTTP/1.1 101 Switching Protocols\r\n" +
			"Upgrade: websocket\r\n" +
			"Connection: Upgrade\r\n" +
			"Sec-WebSocket-Accept: " + accept + "\r\n\r\n",
	); err != nil {
		conn.Close()
		return
	}
	if err := brw.Flush(); err != nil {
		conn.Close()
		return
	}

	c := &wsClient{conn: conn, brw: brw}
	h.mu.Lock()
	h.clients[c] = struct{}{}
	h.mu.Unlock()

	go c.readLoop(h)
}

// broadcast writes a text frame to every connected client, dropping any that
// error (disconnected tab).
func (h *hub) broadcast(payload []byte) {
	h.mu.Lock()
	clients := make([]*wsClient, 0, len(h.clients))
	for c := range h.clients {
		clients = append(clients, c)
	}
	h.mu.Unlock()

	for _, c := range clients {
		if err := c.writeText(payload); err != nil {
			h.drop(c)
		}
	}
}

// broadcastJSON marshals v and broadcasts it as a text frame.
func (h *hub) broadcastJSON(v any) {
	b, err := json.Marshal(v)
	if err != nil {
		fmt.Fprintf(os.Stderr, "devhmr: marshal ws message: %v\n", err)
		return
	}
	h.broadcast(b)
}

func (h *hub) drop(c *wsClient) {
	h.mu.Lock()
	if _, ok := h.clients[c]; ok {
		delete(h.clients, c)
		c.conn.Close()
	}
	h.mu.Unlock()
}

// clientCount reports how many browsers are currently connected.
func (h *hub) clientCount() int {
	h.mu.Lock()
	defer h.mu.Unlock()
	return len(h.clients)
}

// readLoop consumes client frames only to answer pings and to notice a close or
// dead connection, at which point the client is dropped. Application data from
// the client is ignored — this channel is server→browser only.
func (c *wsClient) readLoop(h *hub) {
	defer h.drop(c)
	for {
		op, payload, err := readFrame(c.brw.Reader)
		if err != nil {
			return
		}
		switch op {
		case opClose:
			c.writeFrame(opClose, payload)
			return
		case opPing:
			if c.writeFrame(opPong, payload) != nil {
				return
			}
		}
	}
}

func (c *wsClient) writeText(payload []byte) error { return c.writeFrame(opText, payload) }

// opcodes we handle.
const (
	opText  byte = 0x1
	opClose byte = 0x8
	opPing  byte = 0x9
	opPong  byte = 0xA
)

// writeFrame writes a single unmasked, unfragmented frame (server frames must
// not be masked). Writes are serialized so a broadcast and a pong can't interleave.
func (c *wsClient) writeFrame(opcode byte, payload []byte) error {
	c.wmu.Lock()
	defer c.wmu.Unlock()

	header := make([]byte, 0, 10)
	header = append(header, 0x80|opcode) // FIN + opcode
	n := len(payload)
	switch {
	case n <= 125:
		header = append(header, byte(n))
	case n <= 0xFFFF:
		header = append(header, 126, byte(n>>8), byte(n))
	default:
		header = append(header, 127)
		for i := 7; i >= 0; i-- {
			header = append(header, byte(n>>(8*i)))
		}
	}
	if _, err := c.brw.Write(header); err != nil {
		return err
	}
	if _, err := c.brw.Write(payload); err != nil {
		return err
	}
	return c.brw.Flush()
}

// readFrame reads one frame, unmasking the client payload (client→server frames
// are always masked). Returns the opcode and payload.
func readFrame(r *bufio.Reader) (opcode byte, payload []byte, err error) {
	var h [2]byte
	if _, err = io.ReadFull(r, h[:]); err != nil {
		return
	}
	opcode = h[0] & 0x0F
	masked := h[1]&0x80 != 0
	n := int(h[1] & 0x7F)
	switch n {
	case 126:
		var ext [2]byte
		if _, err = io.ReadFull(r, ext[:]); err != nil {
			return
		}
		n = int(ext[0])<<8 | int(ext[1])
	case 127:
		var ext [8]byte
		if _, err = io.ReadFull(r, ext[:]); err != nil {
			return
		}
		n = 0
		for _, b := range ext {
			n = n<<8 | int(b)
		}
	}
	var mask [4]byte
	if masked {
		if _, err = io.ReadFull(r, mask[:]); err != nil {
			return
		}
	}
	payload = make([]byte, n)
	if _, err = io.ReadFull(r, payload); err != nil {
		return
	}
	if masked {
		for i := range payload {
			payload[i] ^= mask[i%4]
		}
	}
	return
}

// computeAccept derives the Sec-WebSocket-Accept response header from the key.
func computeAccept(key string) string {
	s := sha1.Sum([]byte(key + wsGUID))
	return base64.StdEncoding.EncodeToString(s[:])
}
