//go:build integration

package teamspeak_test

// Integration tests against a live TeamSpeak 3 server (build tag: integration).
//
// Run locally:
//
//	docker compose -f docker-compose.integration.yml up -d --wait
//	TEAMSPEAK_ADDR=127.0.0.1:9987 go test -tags integration ./... -v -timeout 120s
//	docker compose -f docker-compose.integration.yml down
//
// In CI the server is provided by the workflow's service container and
// TEAMSPEAK_ADDR is set automatically.
//
// # Notes
//
//   - A single shared client is reused across tests to avoid TS3 anti-flood
//     protection, which bans IPs that establish too many connections quickly.
//   - Some commands (clientlist, channellist) require elevated server group
//     permissions that the default "Guest" group does not have.  Those tests
//     skip automatically instead of failing hard.

import (
	"bytes"
	"context"
	"fmt"
	"log/slog"
	"os"
	"strconv"
	"strings"
	"sync"
	"testing"
	"time"

	teamspeak "github.com/honeybbq/teamspeak-go"
	"github.com/honeybbq/teamspeak-go/crypto"
)

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

var (
	sharedClient *teamspeak.Client
	sharedOnce   sync.Once
	sharedErr    error
)

var (
	integrationServerPassword         = os.Getenv("TEAMSPEAK_SERVER_PASSWORD")
	integrationDefaultChannel         = os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL")
	integrationDefaultChannelPassword = os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL_PASSWORD")
)

func integrationClientOptions(logger *slog.Logger) []teamspeak.ClientOption {
	opts := make([]teamspeak.ClientOption, 0, 4)
	if logger != nil {
		opts = append(opts, teamspeak.WithLogger(logger))
	}
	if integrationServerPassword != "" {
		opts = append(opts, teamspeak.WithServerPassword(integrationServerPassword))
	}
	if integrationDefaultChannel != "" {
		opts = append(opts, teamspeak.WithDefaultChannel(integrationDefaultChannel))
	}
	if integrationDefaultChannelPassword != "" {
		opts = append(opts, teamspeak.WithDefaultChannelPassword(integrationDefaultChannelPassword))
	}

	return opts
}

func requireTeamSpeakAddr(t *testing.T) string {
	t.Helper()
	addr := os.Getenv("TEAMSPEAK_ADDR")
	if addr == "" {
		t.Skip("TEAMSPEAK_ADDR not set — skip integration test (set TEAMSPEAK_ADDR=host:port to enable)")
	}
	return addr
}

func requireSharedClient(t *testing.T) *teamspeak.Client {
	t.Helper()
	addr := requireTeamSpeakAddr(t)

	sharedOnce.Do(func() {
		id, err := crypto.GenerateIdentity(8)
		if err != nil {
			sharedErr = err
			return
		}
		c := teamspeak.NewClient(id, addr, "teamspeak-go-integ", integrationClientOptions(nil)...)
		if err = c.Connect(); err != nil {
			sharedErr = err
			return
		}
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()
		if err = c.WaitConnected(ctx); err != nil {
			sharedErr = err
			return
		}
		sharedClient = c
	})

	if sharedErr != nil {
		t.Fatalf("shared client setup failed: %v", sharedErr)
	}
	return sharedClient
}

func skipOnPermErr(t *testing.T, err error) {
	t.Helper()
	if err != nil && strings.Contains(err.Error(), "insufficient") {
		t.Skipf("skipping — server returned permission error: %v", err)
	}
}

func newConnectedIntegrationClient(t *testing.T, addr string, nicknamePrefix string, logger *slog.Logger) *teamspeak.Client {
	t.Helper()

	id, err := crypto.GenerateIdentity(8)
	if err != nil {
		t.Fatalf("GenerateIdentity: %v", err)
	}

	nickname := nicknamePrefix + strconv.FormatInt(time.Now().UTC().UnixNano()%1_000_000, 10)

	// TS3 anti-flood may temporarily ban IPs that connect too frequently.
	// Retry with backoff to handle transient bans in CI.
	var client *teamspeak.Client
	for attempt := range 3 {
		if attempt > 0 {
			time.Sleep(time.Duration(attempt*5) * time.Second)
		}
		client = teamspeak.NewClient(id, addr, nickname, integrationClientOptions(logger)...)
		if err = client.Connect(); err != nil {
			continue
		}
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		err = client.WaitConnected(ctx)
		cancel()
		if err == nil {
			break
		}
		_ = client.Disconnect()
	}

	if err != nil {
		t.Fatalf("WaitConnected(%s) after retries: %v", nickname, err)
	}

	t.Cleanup(func() {
		_ = client.Disconnect()
	})

	return client
}

func mapKeys(m map[string]string) []string {
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	return keys
}

func extractJSONField(s string, needle string) string {
	idx := strings.Index(s, needle)
	if idx < 0 {
		return ""
	}
	start := idx + len(needle)
	end := strings.Index(s[start:], "\"")
	if end < 0 {
		return ""
	}
	return s[start : start+end]
}

// ---------------------------------------------------------------------------
// Connection
// ---------------------------------------------------------------------------

func TestIntegration_Connect(t *testing.T) {
	c := requireSharedClient(t)

	clid := c.ClientID()
	if clid == 0 {
		t.Error("expected non-zero client ID after connect")
	}
	t.Logf("connected: clid=%d", clid)
}

func TestIntegration_ConnectWithOptionalHandshakeAuth(t *testing.T) {
	c := requireSharedClient(t)

	t.Logf(
		"connect auth enabled: serverPassword=%t defaultChannel=%t defaultChannelPassword=%t",
		integrationServerPassword != "",
		integrationDefaultChannel != "",
		integrationDefaultChannelPassword != "",
	)
	if c.ClientID() == 0 {
		t.Error("expected non-zero client ID after connect")
	}
}

func TestIntegration_Disconnect(t *testing.T) {
	addr := requireTeamSpeakAddr(t)

	id, err := crypto.GenerateIdentity(8)
	if err != nil {
		t.Fatalf("GenerateIdentity: %v", err)
	}
	c := teamspeak.NewClient(id, addr, "teamspeak-go-integ-disc", integrationClientOptions(nil)...)

	if err = c.Connect(); err != nil {
		t.Fatalf("Connect: %v", err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	if err = c.WaitConnected(ctx); err != nil {
		t.Fatalf("WaitConnected: %v", err)
	}

	disconnected := make(chan error, 1)
	c.OnDisconnected(func(e error) { disconnected <- e })

	if err = c.Disconnect(); err != nil {
		t.Logf("Disconnect returned (non-fatal): %v", err)
	}

	select {
	case <-disconnected:
	case <-time.After(5 * time.Second):
		t.Error("OnDisconnected not fired after Disconnect()")
	}
}

// ---------------------------------------------------------------------------
// Server queries
// ---------------------------------------------------------------------------

func TestIntegration_ListClients(t *testing.T) {
	c := requireSharedClient(t)

	clients, err := c.ListClients()
	skipOnPermErr(t, err)
	if err != nil {
		t.Fatalf("ListClients: %v", err)
	}
	if len(clients) == 0 {
		t.Fatal("expected at least one client (ourselves)")
	}

	ownID := c.ClientID()
	found := false
	for _, cl := range clients {
		if cl.ID == ownID {
			found = true
			t.Logf("self: clid=%d nick=%q cid=%d", cl.ID, cl.Nickname, cl.ChannelID)
			break
		}
	}
	if !found {
		t.Errorf("own clid=%d not found in clientlist", ownID)
	}
}

func TestIntegration_ListChannels(t *testing.T) {
	c := requireSharedClient(t)

	channels, err := c.ListChannels()
	skipOnPermErr(t, err)
	if err != nil {
		t.Fatalf("ListChannels: %v", err)
	}
	if len(channels) == 0 {
		t.Fatal("expected at least one channel (default channel)")
	}
	t.Logf("channels: %d found, first=%q", len(channels), channels[0].Name)
}

func TestIntegration_JoinsConfiguredDefaultChannel(t *testing.T) {
	if integrationDefaultChannel == "" {
		t.Skip("TEAMSPEAK_DEFAULT_CHANNEL not set")
	}

	c := requireSharedClient(t)

	channels, err := c.ListChannels()
	skipOnPermErr(t, err)
	if err != nil {
		t.Fatalf("ListChannels: %v", err)
	}

	clients, err := c.ListClients()
	skipOnPermErr(t, err)
	if err != nil {
		t.Fatalf("ListClients: %v", err)
	}

	var self *teamspeak.ClientInfo
	for i := range clients {
		if clients[i].ID == c.ClientID() {
			self = &clients[i]
			break
		}
	}
	if self == nil {
		t.Fatal("expected to find ourselves in client list")
	}

	var currentChannel *teamspeak.ChannelInfo
	for i := range channels {
		if channels[i].ID == self.ChannelID {
			currentChannel = &channels[i]
			break
		}
	}
	if currentChannel == nil {
		t.Fatalf("expected to resolve current channel for cid=%d", self.ChannelID)
	}
	if currentChannel.Name != integrationDefaultChannel {
		t.Fatalf("expected current channel %q, got %q", integrationDefaultChannel, currentChannel.Name)
	}
}

func TestIntegration_GetClientInfo(t *testing.T) {
	c := requireSharedClient(t)

	info, err := c.GetClientInfo(c.ClientID())
	skipOnPermErr(t, err)
	if err != nil {
		t.Fatalf("GetClientInfo: %v", err)
	}
	if len(info) == 0 {
		t.Fatal("expected non-empty client info map")
	}
	if info["client_nickname"] == "" {
		t.Errorf("expected client_nickname in clientinfo response; got keys: %v", mapKeys(info))
	}
	t.Logf("clientinfo keys: %v", mapKeys(info))
}

// ---------------------------------------------------------------------------
// Text messages
// ---------------------------------------------------------------------------

func TestIntegration_TextPrivateNotifyFields(t *testing.T) {
	addr := requireTeamSpeakAddr(t)

	var logBuf bytes.Buffer
	debugLogger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))

	receiver := newConnectedIntegrationClient(t, addr, "rx", debugLogger)

	sender := newConnectedIntegrationClient(t, addr, "tx", slog.Default())
	senderInfo, err := sender.GetClientInfo(sender.ClientID())
	if err != nil {
		t.Fatalf("sender GetClientInfo: %v", err)
	}
	senderUID := strings.TrimSpace(senderInfo["client_unique_identifier"])
	t.Logf("sender clientinfo keys: %v", mapKeys(senderInfo))

	received := make(chan teamspeak.TextMessage, 1)
	receiver.OnTextMessage(func(msg teamspeak.TextMessage) {
		select {
		case received <- msg:
		default:
		}
	})

	time.Sleep(500 * time.Millisecond)

	probeText := fmt.Sprintf("cursor-probe-%d", time.Now().UTC().UnixNano())
	if err := sender.SendTextMessage(1, uint64(receiver.ClientID()), probeText); err != nil {
		t.Fatalf("sender SendTextMessage private: %v", err)
	}

	var msg teamspeak.TextMessage
	select {
	case msg = <-received:
	case <-time.After(10 * time.Second):
		t.Fatalf("timeout waiting for private text notification; logs=%s", logBuf.String())
	}

	if msg.Message != probeText {
		t.Fatalf("unexpected message text: got %q want %q", msg.Message, probeText)
	}

	logs := logBuf.String()
	t.Logf("receiver logs: %s", logs)

	if !strings.Contains(logs, "\"name\":\"notifytextmessage\"") {
		t.Fatalf("expected notifytextmessage in logs, got: %s", logs)
	}

	targetNeedle := fmt.Sprintf("\"target\":\"%d\"", receiver.ClientID())
	if !strings.Contains(logs, targetNeedle) {
		t.Fatalf("expected raw notify target %s in logs, got: %s", targetNeedle, logs)
	}

	rawInvokerUID := extractJSONField(logs, "\"invokeruid\":\"")
	if rawInvokerUID == "" {
		t.Fatalf("expected raw notify invokeruid in logs, got: %s", logs)
	}

	if msg.TargetMode != 1 {
		t.Fatalf("unexpected target mode: got %d want 1", msg.TargetMode)
	}

	if msg.TargetID != uint64(receiver.ClientID()) {
		t.Fatalf("parsed TargetID mismatch: got %d want %d", msg.TargetID, receiver.ClientID())
	}

	expectedInvokerUID := rawInvokerUID
	if senderUID != "" {
		expectedInvokerUID = senderUID
	}
	if strings.TrimSpace(msg.InvokerUID) != expectedInvokerUID {
		t.Fatalf("parsed InvokerUID mismatch: got %q want %q", msg.InvokerUID, expectedInvokerUID)
	}
}

// ---------------------------------------------------------------------------
// Poke
// ---------------------------------------------------------------------------

func TestIntegration_PokeSendAndReceive(t *testing.T) {
	addr := requireTeamSpeakAddr(t)

	sender := newConnectedIntegrationClient(t, addr, "poke-tx", slog.Default())
	receiver := newConnectedIntegrationClient(t, addr, "poke-rx", slog.Default())

	pokeMsg := fmt.Sprintf("poke-test-%d", time.Now().UTC().UnixNano())

	poked := make(chan teamspeak.PokeEvent, 1)
	receiver.OnPoked(func(e teamspeak.PokeEvent) {
		select {
		case poked <- e:
		default:
		}
	})

	time.Sleep(500 * time.Millisecond)

	if err := sender.Poke(receiver.ClientID(), pokeMsg); err != nil {
		t.Fatalf("Poke: %v", err)
	}
	t.Logf("sent poke from clid=%d to clid=%d msg=%q", sender.ClientID(), receiver.ClientID(), pokeMsg)

	select {
	case evt := <-poked:
		t.Logf("poke received: invoker=%q uid=%q msg=%q", evt.InvokerName, evt.InvokerUID, evt.Message)
		if evt.InvokerID != sender.ClientID() {
			t.Errorf("InvokerID mismatch: got %d want %d", evt.InvokerID, sender.ClientID())
		}
		if evt.Message != pokeMsg {
			t.Errorf("Message mismatch: got %q want %q", evt.Message, pokeMsg)
		}
	case <-time.After(10 * time.Second):
		t.Fatal("timeout waiting for poke notification")
	}
}

func TestIntegration_PokeEmptyMessage(t *testing.T) {
	addr := requireTeamSpeakAddr(t)

	sender := newConnectedIntegrationClient(t, addr, "poke-tx2", slog.Default())
	receiver := newConnectedIntegrationClient(t, addr, "poke-rx2", slog.Default())

	poked := make(chan teamspeak.PokeEvent, 1)
	receiver.OnPoked(func(e teamspeak.PokeEvent) {
		select {
		case poked <- e:
		default:
		}
	})

	time.Sleep(500 * time.Millisecond)

	if err := sender.Poke(receiver.ClientID(), ""); err != nil {
		t.Fatalf("Poke (empty): %v", err)
	}
	t.Logf("sent empty poke from clid=%d to clid=%d", sender.ClientID(), receiver.ClientID())

	select {
	case evt := <-poked:
		t.Logf("empty poke received: invoker=%q msg=%q", evt.InvokerName, evt.Message)
		if evt.Message != "" {
			t.Errorf("expected empty message, got %q", evt.Message)
		}
	case <-time.After(10 * time.Second):
		t.Fatal("timeout waiting for empty poke notification")
	}
}
