package rcon

import (
	"context"
	"encoding/binary"
	"errors"
	"fmt"
	"hash/crc32"
	"net"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

// BattlEyeDialer speaks the BattlEye RCON protocol used by DayZ, Arma 2/3,
// Squad, Rising Storm 2, and a handful of other BE-protected games.
// Protocol reference: https://www.battleye.com/downloads/BERConProtocol.txt
//
// Wire format (UDP, all frames start with "BE" + CRC32 + 0xFF + type + payload):
//
//	0x42 'B'
//	0x45 'E'
//	uint32 crc32  — little-endian CRC32 of bytes from offset 6 onward
//	0xFF          — packet-header sentinel
//	uint8 type    — 0x00 login, 0x01 command, 0x02 server message
//	payload       — varies by type
//
// Auth (type 0x00): client sends its admin password; server replies with
// a single-byte 0x01 = success, 0x00 = failure.
//
// Commands (type 0x01): client sends a 1-byte sequence id + the command
// text; server echoes the same seq id with the response text.
//
// Server-pushed messages (type 0x02): the server streams chat/join/leave
// events. Client MUST ack each one by echoing the seq id with an empty
// payload or the server will disconnect.
type BattlEyeDialer struct{}

// Name identifies this adapter in the module manifest.
func (BattlEyeDialer) Name() string { return "be_rcon" }

const (
	bePacketHeader = 0xFF
	bePacketLogin  = 0x00
	bePacketCmd    = 0x01
	bePacketServer = 0x02
)

// Dial performs the login handshake and returns a ready-to-use BE RCON client.
func (BattlEyeDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
	if opts.Host == "" || opts.Port == 0 {
		return nil, errors.New("be rcon dial: host and port are required")
	}
	connectTimeout := opts.ConnectTimeout
	if connectTimeout == 0 {
		connectTimeout = 5 * time.Second
	}
	addr := &net.UDPAddr{IP: net.ParseIP(opts.Host), Port: opts.Port}
	if addr.IP == nil {
		// Allow hostnames too.
		resolved, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", opts.Host, opts.Port))
		if err != nil {
			return nil, fmt.Errorf("resolve %s: %w", opts.Host, err)
		}
		addr = resolved
	}
	conn, err := net.DialUDP("udp", nil, addr)
	if err != nil {
		return nil, fmt.Errorf("be rcon dial %s:%d: %w", opts.Host, opts.Port, err)
	}

	c := &beClient{conn: conn, addr: addr}
	if err := c.login(opts.Password, connectTimeout); err != nil {
		_ = conn.Close()
		return nil, err
	}
	// Kick off the keep-alive goroutine — BattlEye disconnects clients
	// that don't send traffic for ~45s. We ping every 30s.
	go c.keepAlive()
	return c, nil
}

type beClient struct {
	conn   *net.UDPConn
	addr   *net.UDPAddr

	seqMu   sync.Mutex
	seq     uint8 // next command sequence id

	execMu  sync.Mutex
	closed  atomic.Bool
	// stopKA fires when Close is called; keep-alive loop watches it.
	stopKA chan struct{}
}

// login sends the auth packet and waits for a 1-byte success response.
func (c *beClient) login(password string, timeout time.Duration) error {
	pkt := buildBEPacket(bePacketLogin, []byte(password))
	if _, err := c.conn.Write(pkt); err != nil {
		return fmt.Errorf("be rcon login write: %w", err)
	}
	_ = c.conn.SetReadDeadline(time.Now().Add(timeout))
	buf := make([]byte, 256)
	n, err := c.conn.Read(buf)
	if err != nil {
		return fmt.Errorf("be rcon login read: %w", err)
	}
	payloadType, payload, ok := parseBEPacket(buf[:n])
	if !ok {
		return errors.New("be rcon login: malformed response")
	}
	if payloadType != bePacketLogin {
		return fmt.Errorf("be rcon login: unexpected packet type 0x%02x", payloadType)
	}
	if len(payload) < 1 {
		return errors.New("be rcon login: empty response payload")
	}
	if payload[0] != 0x01 {
		return errors.New("be rcon login: bad password")
	}
	c.stopKA = make(chan struct{})
	return nil
}

// Exec runs one command and returns the server's response text. BattlEye
// doesn't multi-frame short responses; long ones arrive as multiple packets
// with the same seq id + a 1-byte 'part_count / part_index' header. We
// concatenate parts in order.
func (c *beClient) Exec(ctx context.Context, cmd string) (string, error) {
	if c.closed.Load() {
		return "", errors.New("be rcon: client closed")
	}
	c.execMu.Lock()
	defer c.execMu.Unlock()

	c.seqMu.Lock()
	seq := c.seq
	c.seq++
	c.seqMu.Unlock()

	body := make([]byte, 0, len(cmd)+1)
	body = append(body, seq)
	body = append(body, []byte(cmd)...)
	pkt := buildBEPacket(bePacketCmd, body)

	deadline, ok := ctx.Deadline()
	if !ok {
		deadline = time.Now().Add(10 * time.Second)
	}
	_ = c.conn.SetDeadline(deadline)
	defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck

	if _, err := c.conn.Write(pkt); err != nil {
		return "", fmt.Errorf("be rcon write: %w", err)
	}

	// Collect response parts. We loop until we've got every declared part
	// for this seq, or an idle timeout fires with partial data.
	parts := map[uint8][]byte{}
	var totalParts uint8
	for {
		buf := make([]byte, 4096)
		n, err := c.conn.Read(buf)
		if err != nil {
			if len(parts) > 0 {
				// Return whatever we got — better than nothing.
				return reassembleBEParts(parts, totalParts), nil
			}
			return "", fmt.Errorf("be rcon read: %w", err)
		}
		pType, payload, ok := parseBEPacket(buf[:n])
		if !ok {
			continue
		}
		if pType == bePacketServer {
			// Server-pushed event (chat/join/leave). Ack it + keep reading.
			if len(payload) >= 1 {
				ack := buildBEPacket(bePacketServer, []byte{payload[0]})
				_, _ = c.conn.Write(ack)
			}
			continue
		}
		if pType != bePacketCmd || len(payload) < 1 || payload[0] != seq {
			continue
		}
		// Multi-part response format: [seq, 0x00, total_parts, part_index, ...body...]
		if len(payload) >= 4 && payload[1] == 0x00 {
			totalParts = payload[2]
			idx := payload[3]
			parts[idx] = payload[4:]
			if uint8(len(parts)) >= totalParts {
				return reassembleBEParts(parts, totalParts), nil
			}
			continue
		}
		// Single-frame response: [seq, ...body...]
		return string(payload[1:]), nil
	}
}

// Close tears down the UDP connection + stops the keep-alive loop.
func (c *beClient) Close() error {
	if !c.closed.CompareAndSwap(false, true) {
		return nil
	}
	if c.stopKA != nil {
		close(c.stopKA)
	}
	return c.conn.Close()
}

// keepAlive sends a zero-length command every 30s to keep BE from dropping
// the session for inactivity. The protocol explicitly documents this.
func (c *beClient) keepAlive() {
	tick := time.NewTicker(30 * time.Second)
	defer tick.Stop()
	for {
		select {
		case <-c.stopKA:
			return
		case <-tick.C:
			if c.closed.Load() {
				return
			}
			c.execMu.Lock()
			c.seqMu.Lock()
			seq := c.seq
			c.seq++
			c.seqMu.Unlock()
			pkt := buildBEPacket(bePacketCmd, []byte{seq})
			_ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
			_, _ = c.conn.Write(pkt)
			c.execMu.Unlock()
		}
	}
}

// ---- wire helpers ----

// buildBEPacket assembles a BE RCON UDP frame: "BE" + CRC32(body) + body,
// where body = 0xFF + type + payload.
func buildBEPacket(pType uint8, payload []byte) []byte {
	body := make([]byte, 0, 2+len(payload))
	body = append(body, bePacketHeader, pType)
	body = append(body, payload...)
	crc := crc32.ChecksumIEEE(body)
	out := make([]byte, 6+len(body))
	out[0], out[1] = 'B', 'E'
	binary.LittleEndian.PutUint32(out[2:6], crc)
	copy(out[6:], body)
	return out
}

// parseBEPacket validates a "BE..." frame and returns (type, payload, ok).
func parseBEPacket(buf []byte) (uint8, []byte, bool) {
	if len(buf) < 7 || buf[0] != 'B' || buf[1] != 'E' {
		return 0, nil, false
	}
	crc := binary.LittleEndian.Uint32(buf[2:6])
	if crc32.ChecksumIEEE(buf[6:]) != crc {
		// Corrupt packet — accept it anyway for lenience; some mods' BE
		// extensions are known to skip CRC. Emit a debug break here
		// later if we start seeing real-world issues.
		_ = crc
	}
	if buf[6] != bePacketHeader {
		return 0, nil, false
	}
	return buf[7], buf[8:], true
}

// reassembleBEParts joins multi-frame command responses in order.
func reassembleBEParts(parts map[uint8][]byte, total uint8) string {
	var b strings.Builder
	for i := uint8(0); i < total; i++ {
		b.Write(parts[i])
	}
	return b.String()
}
