package rcon

import (
	"bufio"
	"context"
	"encoding/binary"
	"errors"
	"fmt"
	"io"
	"net"
	"strings"
	"sync"
	"time"
)

// SourceDialer speaks Valve's Source RCON protocol — the standard RCON of
// Minecraft, CS2, Rust, Valheim-with-plugins, and most Source-engine games.
//
// Protocol summary (little-endian throughout):
//
//	int32 size            = bytes that follow (i.e. frame length − 4)
//	int32 id              = client-chosen, server echoes
//	int32 type            = 3 auth-request, 2 exec, 0 response, 2 auth-response
//	body  ASCIIZ          = command string or response body, null-terminated
//	byte  trailing null   = required empty string sentinel
//
// size = 4 (id) + 4 (type) + len(body) + 1 (body NUL) + 1 (sentinel NUL)
type SourceDialer struct{}

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

const (
	pktAuth          int32 = 3
	pktExecCommand   int32 = 2
	pktResponseValue int32 = 0
	pktAuthResponse  int32 = 2

	minPktSize = 10
	maxPktSize = 4110
)

func (SourceDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
	if opts.Host == "" || opts.Port == 0 {
		return nil, errors.New("source rcon dial: host and port are required")
	}
	timeout := opts.ConnectTimeout
	if timeout == 0 {
		timeout = 5 * time.Second
	}
	d := &net.Dialer{Timeout: timeout}
	conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", opts.Host, opts.Port))
	if err != nil {
		return nil, fmt.Errorf("source rcon dial %s:%d: %w", opts.Host, opts.Port, err)
	}
	c := &sourceClient{conn: conn, reader: bufio.NewReader(conn), nextID: 1}
	if err := c.authenticate(opts.Password); err != nil {
		_ = conn.Close()
		return nil, fmt.Errorf("source rcon auth: %w", err)
	}
	return c, nil
}

type sourceClient struct {
	conn   net.Conn
	reader *bufio.Reader

	mu     sync.Mutex
	nextID int32
}

// authenticate sends SERVERDATA_AUTH and waits for SERVERDATA_AUTH_RESPONSE.
// Valve's spec says servers send an empty RESPONSE_VALUE first (id echoed),
// then the AUTH_RESPONSE (id = original on success, −1 on failure). Some
// implementations skip the probe — we accept either ordering.
func (c *sourceClient) authenticate(password string) error {
	id := c.assignID()
	if err := c.writePacket(id, pktAuth, password); err != nil {
		return err
	}
	// ASA's RCON handler is slow to answer AUTH during the first few
	// minutes after "Server has completed startup" — sometimes 3-4s per
	// reply when the server is still loading tribe data / saving a
	// snapshot. The previous 2s per-read deadline + 5s overall was
	// tripping the tracker into its 32s exponential backoff on every
	// auth, stranding the panel with "RCON not yet connected" for 5-10
	// min even though the server was otherwise fine. Give AUTH a 12s
	// outer window and 8s per-read — enough slack for ASA's slowest
	// responses without letting a truly dead connection hang forever.
	deadline := time.Now().Add(12 * time.Second)
	for time.Now().Before(deadline) {
		_ = c.conn.SetReadDeadline(time.Now().Add(8 * time.Second))
		gotID, gotType, _, err := c.readPacket()
		if err != nil {
			return err
		}
		if gotType == pktAuthResponse {
			// Spec: id == -1 means auth failed (bad password).
			// Any other id means success. Most servers echo our request id
			// but some (Conan Exiles, Palworld, a few others) return 0 or
			// an unrelated id — treat that as success, don't reject.
			if gotID == -1 {
				return errors.New("invalid password")
			}
			return nil
		}
		// Probe echo (RESPONSE_VALUE with body ""), keep reading for the
		// AUTH_RESPONSE that follows.
	}
	return errors.New("auth timeout")
}

// Exec sends a SERVERDATA_EXECCOMMAND and concatenates RESPONSE_VALUE bodies
// until the server goes silent. For responses that straddle multiple packets
// the spec recommends a "bogus packet trick" (send a second empty packet and
// watch for its echo) but short command output fits in one packet in practice.
func (c *sourceClient) Exec(ctx context.Context, cmd string) (string, error) {
	c.mu.Lock()
	defer c.mu.Unlock()

	if dl, ok := ctx.Deadline(); ok {
		_ = c.conn.SetDeadline(dl)
		defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck
	}

	id := c.assignID()
	if err := c.writePacket(id, pktExecCommand, cmd); err != nil {
		return "", fmt.Errorf("write: %w", err)
	}

	var out strings.Builder
	// First read waits longer — some servers (Palworld especially) take 1–3s
	// to build the response for commands like ShowPlayers. Subsequent reads
	// use a tight idle timeout so we exit promptly once the stream goes quiet.
	const (
		firstTimeout = 5 * time.Second
		idleTimeout  = 800 * time.Millisecond
	)
	readDeadline := firstTimeout
	for {
		_ = c.conn.SetReadDeadline(time.Now().Add(readDeadline))
		readDeadline = idleTimeout
		gotID, gotType, body, err := c.readPacket()
		if err != nil {
			var ne net.Error
			if errors.As(err, &ne) && ne.Timeout() && out.Len() > 0 {
				return out.String(), nil
			}
			if errors.Is(err, io.EOF) && out.Len() > 0 {
				return out.String(), nil
			}
			return out.String(), err
		}
		// Source RCON spec says the server MUST echo our request id on
		// RESPONSE_VALUE. Palworld always returns id=0; Conan Exiles
		// returns a different arbitrary id. We hold mu.Lock() for the
		// whole Exec so only one command is ever in flight — ID matching
		// isn't needed for correctness, so accept any id the server
		// hands us (except -1, which is reserved for auth failures).
		if gotID == -1 {
			continue
		}
		// Spec: RESPONSE_VALUE (type 0) carries command output. Conan
		// Exiles sends command output via AUTH_RESPONSE (type 2) instead —
		// non-spec but functional. We're past the auth phase by this point
		// so accepting either type is safe; anything else we skip.
		if gotType != pktResponseValue && gotType != pktAuthResponse {
			continue
		}
		out.WriteString(body)
		// Body shorter than ~4000 bytes → likely single-packet response.
		if len(body) < 3800 {
			return out.String(), nil
		}
	}
}

// Close shuts down the underlying TCP connection.
func (c *sourceClient) Close() error { return c.conn.Close() }

func (c *sourceClient) assignID() int32 {
	c.nextID++
	if c.nextID <= 0 {
		c.nextID = 1
	}
	return c.nextID
}

// writePacket wire-encodes a Source RCON packet.
func (c *sourceClient) writePacket(id, ptype int32, body string) error {
	bodyBytes := []byte(body)
	pktSize := int32(4 + 4 + len(bodyBytes) + 2) // id + type + body + 2×NUL
	if pktSize > maxPktSize {
		return fmt.Errorf("source rcon: body too large (%d bytes, max %d)", len(bodyBytes), maxPktSize-10)
	}
	buf := make([]byte, 4+pktSize)
	binary.LittleEndian.PutUint32(buf[0:4], uint32(pktSize))
	binary.LittleEndian.PutUint32(buf[4:8], uint32(id))
	binary.LittleEndian.PutUint32(buf[8:12], uint32(ptype))
	copy(buf[12:], bodyBytes)
	// trailing two NUL bytes already zero
	_, err := c.conn.Write(buf)
	return err
}

// readPacket reads one framed Source RCON packet from the underlying reader.
// Returns (id, type, body, err). Body has trailing NULs stripped.
func (c *sourceClient) readPacket() (int32, int32, string, error) {
	var sizeBuf [4]byte
	if _, err := io.ReadFull(c.reader, sizeBuf[:]); err != nil {
		return 0, 0, "", err
	}
	size := int32(binary.LittleEndian.Uint32(sizeBuf[:]))
	if size < minPktSize || size > maxPktSize {
		return 0, 0, "", fmt.Errorf("source rcon: bogus packet size %d", size)
	}
	rest := make([]byte, size)
	if _, err := io.ReadFull(c.reader, rest); err != nil {
		return 0, 0, "", err
	}
	id := int32(binary.LittleEndian.Uint32(rest[0:4]))
	ptype := int32(binary.LittleEndian.Uint32(rest[4:8]))
	bodyEnd := size - 2 // trim both trailing NULs
	if bodyEnd < 8 {
		bodyEnd = 8
	}
	body := string(rest[8:bodyEnd])
	return id, ptype, body, nil
}
