package redis

import (
	"context"
	"errors"
	"fmt"
	"net"
	"reflect"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/redis/go-redis/v9/auth"
	"github.com/redis/go-redis/v9/internal/pool"
	"github.com/redis/go-redis/v9/internal/proto"

	. "github.com/bsm/ginkgo/v2"
	. "github.com/bsm/gomega"
)

var ctx = context.TODO()

type capturingLogger struct {
	mu   sync.Mutex
	logs []string
}

func (l *capturingLogger) Printf(_ context.Context, format string, v ...interface{}) {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.logs = append(l.logs, fmt.Sprintf(format, v...))
}

func (l *capturingLogger) contains(substr string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	for _, msg := range l.logs {
		if strings.Contains(msg, substr) {
			return true
		}
	}
	return false
}

var _ = Describe("newClusterState", func() {
	var state *clusterState

	createClusterState := func(slots []ClusterSlot) *clusterState {
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		state, err := newClusterState(nodes, slots, "10.10.10.10:1234")
		Expect(err).NotTo(HaveOccurred())
		return state
	}

	Describe("sorting", func() {
		BeforeEach(func() {
			state = createClusterState([]ClusterSlot{{
				Start: 1000,
				End:   1999,
			}, {
				Start: 0,
				End:   999,
			}, {
				Start: 2000,
				End:   2999,
			}})
		})

		It("sorts slots", func() {
			Expect(state.slots).To(Equal([]*clusterSlot{
				{start: 0, end: 999, nodes: nil},
				{start: 1000, end: 1999, nodes: nil},
				{start: 2000, end: 2999, nodes: nil},
			}))
		})
	})

	Describe("loopback", func() {
		BeforeEach(func() {
			state = createClusterState([]ClusterSlot{{
				Nodes: []ClusterNode{{Addr: "127.0.0.1:7001"}},
			}, {
				Nodes: []ClusterNode{{Addr: "127.0.0.1:7002"}},
			}, {
				Nodes: []ClusterNode{{Addr: "1.2.3.4:1234"}},
			}, {
				Nodes: []ClusterNode{{Addr: ":1234"}},
			}})
		})

		It("replaces loopback hosts in addresses", func() {
			slotAddr := func(slot *clusterSlot) string {
				return slot.nodes[0].Client.Options().Addr
			}

			Expect(slotAddr(state.slots[0])).To(Equal("10.10.10.10:7001"))
			Expect(slotAddr(state.slots[1])).To(Equal("10.10.10.10:7002"))
			Expect(slotAddr(state.slots[2])).To(Equal("1.2.3.4:1234"))
			Expect(slotAddr(state.slots[3])).To(Equal(":1234"))
		})
	})

	Describe("NodeAddress", func() {
		BeforeEach(func() {
			state = createClusterState([]ClusterSlot{{
				Nodes: []ClusterNode{{Addr: "127.0.0.1:7001"}},
			}, {
				Nodes: []ClusterNode{{Addr: "127.0.0.1:7002"}},
			}, {
				Nodes: []ClusterNode{{Addr: "1.2.3.4:1234"}},
			}, {
				Nodes: []ClusterNode{{Addr: ":1234"}},
			}})
		})

		It("preserves node address from ClusterSlots before loopback replacement", func() {
			slotNodeAddress := func(slot *clusterSlot) string {
				return slot.nodes[0].Client.NodeAddress()
			}

			// Node addresses should be preserved even when Addr is transformed
			Expect(slotNodeAddress(state.slots[0])).To(Equal("127.0.0.1:7001"))
			Expect(slotNodeAddress(state.slots[1])).To(Equal("127.0.0.1:7002"))
			Expect(slotNodeAddress(state.slots[2])).To(Equal("1.2.3.4:1234"))
			Expect(slotNodeAddress(state.slots[3])).To(Equal(":1234"))
		})

		It("has different Addr and NodeAddress when loopback is replaced", func() {
			// For loopback addresses, Addr should be transformed but NodeAddress preserved
			slot0 := state.slots[0]
			Expect(slot0.nodes[0].Client.Options().Addr).To(Equal("10.10.10.10:7001"))
			Expect(slot0.nodes[0].Client.NodeAddress()).To(Equal("127.0.0.1:7001"))

			// For non-loopback addresses, Addr and NodeAddress should be the same
			slot2 := state.slots[2]
			Expect(slot2.nodes[0].Client.Options().Addr).To(Equal("1.2.3.4:1234"))
			Expect(slot2.nodes[0].Client.NodeAddress()).To(Equal("1.2.3.4:1234"))
		})
	})

	Describe("NodeAddress with FQDN", func() {
		BeforeEach(func() {
			// Simulate FQDN endpoints that might be resolved/transformed
			state = createClusterState([]ClusterSlot{{
				Nodes: []ClusterNode{{Addr: "redis-master.example.com:6379"}},
			}, {
				Nodes: []ClusterNode{{Addr: "redis-replica-1.example.com:6379"}},
			}, {
				Nodes: []ClusterNode{{Addr: "redis-replica-2.example.com:6379"}},
			}})
		})

		It("preserves FQDN node addresses", func() {
			slotNodeAddress := func(slot *clusterSlot) string {
				return slot.nodes[0].Client.NodeAddress()
			}

			Expect(slotNodeAddress(state.slots[0])).To(Equal("redis-master.example.com:6379"))
			Expect(slotNodeAddress(state.slots[1])).To(Equal("redis-replica-1.example.com:6379"))
			Expect(slotNodeAddress(state.slots[2])).To(Equal("redis-replica-2.example.com:6379"))
		})
	})

	Describe("NodeAddress with multiple nodes per slot", func() {
		BeforeEach(func() {
			state = createClusterState([]ClusterSlot{{
				Start: 0,
				End:   5460,
				Nodes: []ClusterNode{
					{Addr: "127.0.0.1:7001"}, // master
					{Addr: "127.0.0.1:7004"}, // replica
				},
			}, {
				Start: 5461,
				End:   10922,
				Nodes: []ClusterNode{
					{Addr: "master-2.redis.local:6379"},  // master
					{Addr: "replica-2.redis.local:6379"}, // replica
				},
			}})
		})

		It("preserves node addresses for all nodes in a slot", func() {
			// First slot - loopback addresses
			slot0 := state.slots[0]
			Expect(slot0.nodes[0].Client.NodeAddress()).To(Equal("127.0.0.1:7001"))
			Expect(slot0.nodes[1].Client.NodeAddress()).To(Equal("127.0.0.1:7004"))

			// Verify Addr is transformed for loopback
			Expect(slot0.nodes[0].Client.Options().Addr).To(Equal("10.10.10.10:7001"))
			Expect(slot0.nodes[1].Client.Options().Addr).To(Equal("10.10.10.10:7004"))

			// Second slot - FQDN addresses (no transformation)
			slot1 := state.slots[1]
			Expect(slot1.nodes[0].Client.NodeAddress()).To(Equal("master-2.redis.local:6379"))
			Expect(slot1.nodes[1].Client.NodeAddress()).To(Equal("replica-2.redis.local:6379"))

			// Verify Addr is same as NodeAddress for non-loopback
			Expect(slot1.nodes[0].Client.Options().Addr).To(Equal("master-2.redis.local:6379"))
			Expect(slot1.nodes[1].Client.Options().Addr).To(Equal("replica-2.redis.local:6379"))
		})
	})
})

// TestNodeAddress tests that NodeAddress is correctly preserved
// when cluster nodes are created from ClusterSlots responses.
func TestNodeAddress(t *testing.T) {
	t.Run("preserves node address when loopback is replaced", func(t *testing.T) {
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		defer nodes.Close()

		// Create cluster state with loopback addresses
		// Origin is non-loopback, so loopback addresses will be replaced
		slots := []ClusterSlot{{
			Start: 0,
			End:   5460,
			Nodes: []ClusterNode{{Addr: "127.0.0.1:7001"}},
		}, {
			Start: 5461,
			End:   10922,
			Nodes: []ClusterNode{{Addr: "127.0.0.1:7002"}},
		}}

		state, err := newClusterState(nodes, slots, "10.10.10.10:1234")
		if err != nil {
			t.Fatalf("newClusterState failed: %v", err)
		}

		// Verify Addr is transformed (loopback replaced with origin host)
		if got := state.slots[0].nodes[0].Client.Options().Addr; got != "10.10.10.10:7001" {
			t.Errorf("Addr = %q, want %q", got, "10.10.10.10:7001")
		}
		if got := state.slots[1].nodes[0].Client.Options().Addr; got != "10.10.10.10:7002" {
			t.Errorf("Addr = %q, want %q", got, "10.10.10.10:7002")
		}

		// Verify NodeAddress is preserved (original from ClusterSlots)
		if got := state.slots[0].nodes[0].Client.NodeAddress(); got != "127.0.0.1:7001" {
			t.Errorf("NodeAddress = %q, want %q", got, "127.0.0.1:7001")
		}
		if got := state.slots[1].nodes[0].Client.NodeAddress(); got != "127.0.0.1:7002" {
			t.Errorf("NodeAddress = %q, want %q", got, "127.0.0.1:7002")
		}
	})

	t.Run("preserves FQDN node addresses", func(t *testing.T) {
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		defer nodes.Close()

		slots := []ClusterSlot{{
			Start: 0,
			End:   5460,
			Nodes: []ClusterNode{{Addr: "redis-master.example.com:6379"}},
		}, {
			Start: 5461,
			End:   10922,
			Nodes: []ClusterNode{{Addr: "redis-replica.example.com:6379"}},
		}}

		state, err := newClusterState(nodes, slots, "10.10.10.10:1234")
		if err != nil {
			t.Fatalf("newClusterState failed: %v", err)
		}

		// For non-loopback addresses, Addr and NodeAddress should be the same
		if got := state.slots[0].nodes[0].Client.Options().Addr; got != "redis-master.example.com:6379" {
			t.Errorf("Addr = %q, want %q", got, "redis-master.example.com:6379")
		}
		if got := state.slots[0].nodes[0].Client.NodeAddress(); got != "redis-master.example.com:6379" {
			t.Errorf("NodeAddress = %q, want %q", got, "redis-master.example.com:6379")
		}

		if got := state.slots[1].nodes[0].Client.Options().Addr; got != "redis-replica.example.com:6379" {
			t.Errorf("Addr = %q, want %q", got, "redis-replica.example.com:6379")
		}
		if got := state.slots[1].nodes[0].Client.NodeAddress(); got != "redis-replica.example.com:6379" {
			t.Errorf("NodeAddress = %q, want %q", got, "redis-replica.example.com:6379")
		}
	})

	t.Run("preserves node address for multiple nodes per slot", func(t *testing.T) {
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		defer nodes.Close()

		slots := []ClusterSlot{{
			Start: 0,
			End:   5460,
			Nodes: []ClusterNode{
				{Addr: "127.0.0.1:7001"}, // master
				{Addr: "127.0.0.1:7004"}, // replica
			},
		}}

		state, err := newClusterState(nodes, slots, "10.10.10.10:1234")
		if err != nil {
			t.Fatalf("newClusterState failed: %v", err)
		}

		// Master node
		if got := state.slots[0].nodes[0].Client.Options().Addr; got != "10.10.10.10:7001" {
			t.Errorf("Master Addr = %q, want %q", got, "10.10.10.10:7001")
		}
		if got := state.slots[0].nodes[0].Client.NodeAddress(); got != "127.0.0.1:7001" {
			t.Errorf("Master NodeAddress = %q, want %q", got, "127.0.0.1:7001")
		}

		// Replica node
		if got := state.slots[0].nodes[1].Client.Options().Addr; got != "10.10.10.10:7004" {
			t.Errorf("Replica Addr = %q, want %q", got, "10.10.10.10:7004")
		}
		if got := state.slots[0].nodes[1].Client.NodeAddress(); got != "127.0.0.1:7004" {
			t.Errorf("Replica NodeAddress = %q, want %q", got, "127.0.0.1:7004")
		}
	})

	t.Run("GetOrCreate without node address defaults to Addr", func(t *testing.T) {
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		defer nodes.Close()

		// GetOrCreate without node address (e.g., from MOVED/ASK error)
		node, err := nodes.GetOrCreate("10.10.10.10:7001")
		if err != nil {
			t.Fatalf("GetOrCreate failed: %v", err)
		}

		// NodeAddress should default to Addr when not provided
		if got := node.Client.NodeAddress(); got != "10.10.10.10:7001" {
			t.Errorf("NodeAddress = %q, want %q", got, "10.10.10.10:7001")
		}

		// Addr should be set correctly
		if got := node.Client.Options().Addr; got != "10.10.10.10:7001" {
			t.Errorf("Addr = %q, want %q", got, "10.10.10.10:7001")
		}
	})

	t.Run("GetOrCreateWithNodeAddress sets node address", func(t *testing.T) {
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		defer nodes.Close()

		// GetOrCreateWithNodeAddress with node address
		node, err := nodes.GetOrCreateWithNodeAddress("10.10.10.10:7001", "127.0.0.1:7001")
		if err != nil {
			t.Fatalf("GetOrCreateWithNodeAddress failed: %v", err)
		}

		// NodeAddress should be set
		if got := node.Client.NodeAddress(); got != "127.0.0.1:7001" {
			t.Errorf("NodeAddress = %q, want %q", got, "127.0.0.1:7001")
		}

		// Addr should be the transformed address
		if got := node.Client.Options().Addr; got != "10.10.10.10:7001" {
			t.Errorf("Addr = %q, want %q", got, "10.10.10.10:7001")
		}
	})
}

// TestNewClusterState_PortZero verifies that nodes reported by CLUSTER SLOTS
// with a port of 0 — the topology produced by TLS-only Redis clusters started
// with `--port 0 --tls-port 6379` — get their dial address normalized so they
// do not surface to the dial path as ":0".
//
// Regression test for https://github.com/redis/go-redis/issues/3726
// PubSub in v9.18.0 fails in TLS-only clusters because newClusterState stores
// the verbatim ":0" port into clusterNode.Client.opt.Addr and the PubSub path
// dials that address directly with no MOVED/seed fallback, producing
// `dial tcp <ip>:0: connection refused`.
//
// The fix is expected to use the origin endpoint's port as a fallback whenever
// CLUSTER SLOTS reports port 0 — the origin is by definition reachable, since
// that is the address we used to obtain the slot map in the first place.
func TestNewClusterState_PortZero(t *testing.T) {
	t.Run("non-loopback origin replaces :0 port with origin port", func(t *testing.T) {
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		defer nodes.Close()

		// Simulates a TLS-only cluster: CLUSTER SLOTS reports port 0 because
		// the server is configured with `--port 0 --tls-port 6379`.
		slots := []ClusterSlot{{
			Start: 0,
			End:   5460,
			Nodes: []ClusterNode{{Addr: "172.30.0.10:0"}},
		}, {
			Start: 5461,
			End:   10922,
			Nodes: []ClusterNode{{Addr: "172.30.0.11:0"}},
		}}

		// Origin is the TLS configuration endpoint that returned this slot map.
		state, err := newClusterState(nodes, slots, "172.30.0.10:6379")
		if err != nil {
			t.Fatalf("newClusterState failed: %v", err)
		}

		for i, want := range []string{"172.30.0.10:6379", "172.30.0.11:6379"} {
			got := state.slots[i].nodes[0].Client.Options().Addr
			if _, port, _ := net.SplitHostPort(got); port == "0" {
				t.Fatalf("slot %d Addr = %q: port 0 reached the dial path "+
					"(see #3726: PubSub dials this verbatim and fails with "+
					"`connection refused`)", i, got)
			}
			if got != want {
				t.Errorf("slot %d Addr = %q, want %q (origin port should be "+
					"used as fallback for :0 nodes)", i, got, want)
			}

			// NodeAddress documents the raw server-reported endpoint
			// (see options.go: "the exact endpoint string returned by
			// CLUSTER SLOTS before any resolution or transformation").
			// The port-0 fix is a transformation, so NodeAddress keeps
			// the original ":0" value used by maintenance-notification
			// matching. If the design changes this, update this assertion.
			wantNodeAddr := []string{"172.30.0.10:0", "172.30.0.11:0"}[i]
			if got := state.slots[i].nodes[0].Client.NodeAddress(); got != wantNodeAddr {
				t.Errorf("slot %d NodeAddress = %q, want %q (raw server value)",
					i, got, wantNodeAddr)
			}
		}
	})

	t.Run("non-zero ports are unchanged", func(t *testing.T) {
		// Guards against the fix over-rewriting ports for normal clusters.
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		defer nodes.Close()

		slots := []ClusterSlot{{
			Nodes: []ClusterNode{{Addr: "172.30.0.10:6380"}},
		}}

		state, err := newClusterState(nodes, slots, "172.30.0.10:6379")
		if err != nil {
			t.Fatalf("newClusterState failed: %v", err)
		}

		if got := state.slots[0].nodes[0].Client.Options().Addr; got != "172.30.0.10:6380" {
			t.Errorf("Addr = %q, want %q (non-zero ports must be preserved)",
				got, "172.30.0.10:6380")
		}
	})

	t.Run("loopback :0 follows loopback substitution and uses origin port", func(t *testing.T) {
		// Combines the two transformations newClusterState already does
		// (loopback substitution + the new port-0 fallback) and asserts
		// the order is sane: replace the loopback host with the origin
		// host, then replace the :0 port with the origin port.
		opt := &ClusterOptions{}
		opt.init()
		nodes := newClusterNodes(opt)
		defer nodes.Close()

		slots := []ClusterSlot{{
			Nodes: []ClusterNode{{Addr: "127.0.0.1:0"}},
		}}

		state, err := newClusterState(nodes, slots, "172.30.0.10:6379")
		if err != nil {
			t.Fatalf("newClusterState failed: %v", err)
		}

		if got := state.slots[0].nodes[0].Client.Options().Addr; got != "172.30.0.10:6379" {
			t.Errorf("Addr = %q, want %q (loopback host + :0 port should both "+
				"be replaced from the origin)", got, "172.30.0.10:6379")
		}
	})
}

type fixedHash string

func (h fixedHash) Get(string) string {
	return string(h)
}

func TestRingSetAddrsAndRebalanceRace(t *testing.T) {
	const (
		ringShard1Name = "ringShardOne"
		ringShard2Name = "ringShardTwo"

		ringShard1Port = "6390"
		ringShard2Port = "6391"
	)

	ring := NewRing(&RingOptions{
		Addrs: map[string]string{
			ringShard1Name: ":" + ringShard1Port,
		},
		// Disable heartbeat
		HeartbeatFrequency: 1 * time.Hour,
		NewConsistentHash: func(shards []string) ConsistentHash {
			switch len(shards) {
			case 1:
				return fixedHash(ringShard1Name)
			case 2:
				return fixedHash(ringShard2Name)
			default:
				t.Fatalf("Unexpected number of shards: %v", shards)
				return nil
			}
		},
	})
	defer ring.Close()

	// Continuously update addresses by adding and removing one address
	updatesDone := make(chan struct{})
	defer func() { close(updatesDone) }()
	go func() {
		for i := 0; ; i++ {
			select {
			case <-updatesDone:
				return
			default:
				if i%2 == 0 {
					ring.SetAddrs(map[string]string{
						ringShard1Name: ":" + ringShard1Port,
					})
				} else {
					ring.SetAddrs(map[string]string{
						ringShard1Name: ":" + ringShard1Port,
						ringShard2Name: ":" + ringShard2Port,
					})
				}
			}
		}
	}()

	timer := time.NewTimer(1 * time.Second)
	for running := true; running; {
		select {
		case <-timer.C:
			running = false
		default:
			shard, err := ring.sharding.GetByKey("whatever")
			if err == nil && shard == nil {
				t.Fatal("shard is nil")
			}
		}
	}
}

func BenchmarkRingShardingRebalanceLocked(b *testing.B) {
	opts := &RingOptions{
		Addrs: make(map[string]string),
		// Disable heartbeat
		HeartbeatFrequency: 1 * time.Hour,
	}
	for i := 0; i < 100; i++ {
		opts.Addrs[fmt.Sprintf("shard%d", i)] = fmt.Sprintf(":63%02d", i)
	}

	ring := NewRing(opts)
	defer ring.Close()

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		ring.sharding.rebalanceLocked()
	}
}

type testCounter struct {
	mu sync.Mutex
	t  *testing.T
	m  map[string]int
}

func newTestCounter(t *testing.T) *testCounter {
	return &testCounter{t: t, m: make(map[string]int)}
}

func (ct *testCounter) increment(key string) {
	ct.mu.Lock()
	defer ct.mu.Unlock()
	ct.m[key]++
}

func (ct *testCounter) expect(values map[string]int) {
	ct.mu.Lock()
	defer ct.mu.Unlock()
	ct.t.Helper()
	if !reflect.DeepEqual(values, ct.m) {
		ct.t.Errorf("expected %v != actual %v", values, ct.m)
	}
}

// testOnCloseHookID is the id used by the ring-shard cleanup tests when
// registering a close hook against the internal onCloseHooks registry.
const testOnCloseHookID = "test-close-counter"

func TestRingShardsCleanup(t *testing.T) {
	const (
		ringShard1Name = "ringShardOne"
		ringShard2Name = "ringShardTwo"

		ringShard1Addr = "shard1.test"
		ringShard2Addr = "shard2.test"
	)

	t.Run("closes unused shards", func(t *testing.T) {
		closeCounter := newTestCounter(t)

		ring := NewRing(&RingOptions{
			Addrs: map[string]string{
				ringShard1Name: ringShard1Addr,
				ringShard2Name: ringShard2Addr,
			},
			NewClient: func(opt *Options) *Client {
				c := NewClient(opt)
				c.baseClient.onClose.register(testOnCloseHookID, func() error {
					closeCounter.increment(opt.Addr)
					return nil
				})
				return c
			},
		})
		closeCounter.expect(map[string]int{})

		// no change due to the same addresses
		ring.SetAddrs(map[string]string{
			ringShard1Name: ringShard1Addr,
			ringShard2Name: ringShard2Addr,
		})
		closeCounter.expect(map[string]int{})

		ring.SetAddrs(map[string]string{
			ringShard1Name: ringShard1Addr,
		})
		closeCounter.expect(map[string]int{ringShard2Addr: 1})

		ring.SetAddrs(map[string]string{
			ringShard2Name: ringShard2Addr,
		})
		closeCounter.expect(map[string]int{ringShard1Addr: 1, ringShard2Addr: 1})

		ring.Close()
		closeCounter.expect(map[string]int{ringShard1Addr: 1, ringShard2Addr: 2})
	})

	t.Run("closes created shards if ring was closed", func(t *testing.T) {
		createCounter := newTestCounter(t)
		closeCounter := newTestCounter(t)

		var (
			ring        *Ring
			shouldClose atomic.Int32
		)

		ring = NewRing(&RingOptions{
			Addrs: map[string]string{
				ringShard1Name: ringShard1Addr,
			},
			NewClient: func(opt *Options) *Client {
				if shouldClose.Load() != 0 {
					ring.Close()
				}
				createCounter.increment(opt.Addr)
				c := NewClient(opt)
				c.baseClient.onClose.register(testOnCloseHookID, func() error {
					closeCounter.increment(opt.Addr)
					return nil
				})
				return c
			},
		})
		createCounter.expect(map[string]int{ringShard1Addr: 1})
		closeCounter.expect(map[string]int{})

		shouldClose.Store(1)

		ring.SetAddrs(map[string]string{
			ringShard2Name: ringShard2Addr,
		})
		createCounter.expect(map[string]int{ringShard1Addr: 1, ringShard2Addr: 1})
		closeCounter.expect(map[string]int{ringShard1Addr: 1, ringShard2Addr: 1})
	})
}

//------------------------------------------------------------------------------

type timeoutErr struct {
	error
}

func (e timeoutErr) Timeout() bool {
	return true
}

func (e timeoutErr) Temporary() bool {
	return true
}

func (e timeoutErr) Error() string {
	return "i/o timeout"
}

// NonRedisEnterprise: exercises connection-pool replacement internals against a
// local Redis and requires a localhost server; not meaningful against a remote
// Enterprise cluster.
var _ = Describe("withConn", Label("NonRedisEnterprise"), func() {
	var client *Client

	BeforeEach(func() {
		client = NewClient(&Options{
			PoolSize: 1,
		})
	})

	AfterEach(func() {
		client.Close()
	})

	It("should replace the connection in the pool when there is no error", func() {
		var conn *pool.Conn

		client.withConn(ctx, func(ctx context.Context, c *pool.Conn) error {
			conn = c
			return nil
		})

		newConn, err := client.connPool.Get(ctx)
		Expect(err).To(BeNil())
		Expect(newConn).To(Equal(conn))
	})

	It("should replace the connection in the pool when there is an error not related to a bad connection", func() {
		var conn *pool.Conn

		client.withConn(ctx, func(ctx context.Context, c *pool.Conn) error {
			conn = c
			return proto.RedisError("LOADING")
		})

		newConn, err := client.connPool.Get(ctx)
		Expect(err).To(BeNil())
		Expect(newConn).To(Equal(conn))
	})

	It("should remove the connection from the pool when it times out", func() {
		var conn *pool.Conn

		client.withConn(ctx, func(ctx context.Context, c *pool.Conn) error {
			conn = c
			return timeoutErr{}
		})

		newConn, err := client.connPool.Get(ctx)
		Expect(err).To(BeNil())
		Expect(newConn).NotTo(Equal(conn))
		Expect(client.connPool.Len()).To(Equal(1))
	})
})

var _ = Describe("ClusterClient", func() {
	var client *ClusterClient

	BeforeEach(func() {
		client = &ClusterClient{}
	})

	Describe("cmdSlot", func() {
		It("select slot from args for GETKEYSINSLOT command", func() {
			cmd := NewStringSliceCmd(ctx, "cluster", "getkeysinslot", 100, 200)

			slot := client.cmdSlot(cmd, -1)
			Expect(slot).To(Equal(100))
		})

		It("select slot from args for COUNTKEYSINSLOT command", func() {
			cmd := NewStringSliceCmd(ctx, "cluster", "countkeysinslot", 100)

			slot := client.cmdSlot(cmd, -1)
			Expect(slot).To(Equal(100))
		})

		It("follows preferred random slot", func() {
			cmd := NewStatusCmd(ctx, "ping")

			slot := client.cmdSlot(cmd, 101)
			Expect(slot).To(Equal(101))
		})
	})
})

var _ = Describe("isLoopback", func() {
	DescribeTable(
		"should correctly identify loopback addresses",
		func(host string, expected bool) {
			result := isLoopback(host)
			Expect(result).To(Equal(expected))
		},
		// IP addresses
		Entry("IPv4 loopback", "127.0.0.1", true),
		Entry("IPv6 loopback", "::1", true),
		Entry("IPv4 non-loopback", "192.168.1.1", false),
		Entry("IPv6 non-loopback", "2001:db8::1", false),

		// Well-known loopback hostnames
		Entry("localhost lowercase", "localhost", true),
		Entry("localhost uppercase", "LOCALHOST", true),
		Entry("localhost mixed case", "LocalHost", true),

		// Docker-specific loopbacks
		Entry("host.docker.internal", "host.docker.internal", true),
		Entry("HOST.DOCKER.INTERNAL", "HOST.DOCKER.INTERNAL", true),
		Entry("custom.docker.internal", "custom.docker.internal", true),
		Entry("app.docker.internal", "app.docker.internal", true),

		// Non-loopback hostnames
		Entry("redis hostname", "redis-cluster", false),
		Entry("FQDN", "redis.example.com", false),
		Entry("docker but not internal", "redis.docker.com", false),

		// Edge cases
		Entry("empty string", "", false),
		Entry("invalid IP", "256.256.256.256", false),
		Entry("partial docker internal", "docker.internal", false),
	)
})

// TestOnCloseHooks_RunInReverseRegistrationOrder verifies that hooks are invoked in
// REVERSE registration order (LIFO): a hook registered later is a consumer of state an
// earlier registration provides, so it must run first. Concretely, an autopipeliner
// drain (registered lazily) must run before the Sentinel failover teardown (registered
// at construction) so the drain can still resolve the master address. See onCloseHooks.run.
func TestOnCloseHooks_RunInReverseRegistrationOrder(t *testing.T) {
	h := &onCloseHooks{}
	var calls []string

	h.register("a", func() error { calls = append(calls, "a"); return nil })
	h.register("b", func() error { calls = append(calls, "b"); return nil })
	h.register("c", func() error { calls = append(calls, "c"); return nil })

	if err := h.run(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	want := []string{"c", "b", "a"}
	if !reflect.DeepEqual(calls, want) {
		t.Fatalf("run order = %v, want %v (LIFO)", calls, want)
	}
}

// TestOnCloseHooks_RegisterSameIDReplaces is the regression test for issue
// #3772. Registering the same id repeatedly must replace the existing
// callback rather than chain onto it, so the registry stays bounded even
// under storm-like re-registration (the exact scenario that previously leaked
// when initConn re-wrapped c.onClose on every connection init).
func TestOnCloseHooks_RegisterSameIDReplaces(t *testing.T) {
	h := &onCloseHooks{}
	const id = "same-id"
	const iterations = 10_000

	var lastSeen atomic.Int32
	for i := 0; i < iterations; i++ {
		i := int32(i)
		h.register(id, func() error { lastSeen.Store(i); return nil })
	}

	if got := len(h.order); got != 1 {
		t.Fatalf("order length after %d re-registrations = %d, want 1", iterations, got)
	}
	if got := len(h.hooks); got != 1 {
		t.Fatalf("hooks map size after %d re-registrations = %d, want 1", iterations, got)
	}

	if err := h.run(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got := lastSeen.Load(); got != iterations-1 {
		t.Fatalf("last-registered callback not invoked: lastSeen = %d, want %d", got, iterations-1)
	}
}

// TestOnCloseHooks_RegisterAfterRunIsRejected pins the contract the cluster FD
// router's per-node evict hook relies on: once run has taken its snapshot the
// owner is closing, and a callback registered from then on would never fire —
// so register must report false and store nothing, instead of silently
// accepting a hook that leaks whatever it was meant to clean up (cursor bugbot
// on #4002: a node client closing between a child's publication and its evict
// hook's registration left the child in the router map for good).
func TestOnCloseHooks_RegisterAfterRunIsRejected(t *testing.T) {
	h := &onCloseHooks{}
	var early, late atomic.Int32

	if !h.register("early", func() error { early.Add(1); return nil }) {
		t.Fatal("register before run must report true")
	}
	if err := h.run(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got := early.Load(); got != 1 {
		t.Fatalf("early hook invoked %d times, want 1", got)
	}

	if h.register("late", func() error { late.Add(1); return nil }) {
		t.Fatal("register after run must report false: the owner is closing and the hook would never fire")
	}
	if _, stored := h.hooks["late"]; stored {
		t.Fatal("a rejected registration must not be stored")
	}
	// A second run (Close is idempotent at the owner level) must not surface it.
	if err := h.run(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got := late.Load(); got != 0 {
		t.Fatalf("late hook invoked %d times, want 0", got)
	}
}

// TestOnCloseHooks_DistinctIDsCoexist guarantees the dedup behavior does not
// discard hooks from other callers: registering new ids must never drop
// previously registered ids.
func TestOnCloseHooks_DistinctIDsCoexist(t *testing.T) {
	h := &onCloseHooks{}
	var aCount, bCount atomic.Int32

	h.register("a", func() error { aCount.Add(1); return nil })
	h.register("b", func() error { bCount.Add(1); return nil })
	// Re-registering "a" must not drop "b".
	h.register("a", func() error { aCount.Add(1); return nil })

	if err := h.run(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if a, b := aCount.Load(), bCount.Load(); a != 1 || b != 1 {
		t.Fatalf("call counts a=%d b=%d, want a=1 b=1", a, b)
	}
}

// TestOnCloseHooks_Unregister verifies that unregister removes a hook and
// that running after unregister does not invoke it.
func TestOnCloseHooks_Unregister(t *testing.T) {
	h := &onCloseHooks{}
	var aCalled, bCalled bool

	h.register("a", func() error { aCalled = true; return nil })
	h.register("b", func() error { bCalled = true; return nil })
	h.unregister("a")
	h.unregister("missing") // no-op must not panic

	if err := h.run(); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if aCalled {
		t.Fatal("unregistered hook was invoked")
	}
	if !bCalled {
		t.Fatal("remaining hook was not invoked")
	}
	if got := len(h.order); got != 1 {
		t.Fatalf("order length = %d, want 1", got)
	}
}

// TestOnCloseHooks_AllRunOnError confirms every hook is invoked even if an
// earlier one returns an error, and that the first error in INVOCATION order (LIFO) is
// returned. "b" runs before "a" under LIFO, so its error is the first non-nil one.
func TestOnCloseHooks_AllRunOnError(t *testing.T) {
	h := &onCloseHooks{}
	var called [3]bool
	errFirst := fmt.Errorf("first")
	errSecond := fmt.Errorf("second")

	h.register("a", func() error { called[0] = true; return errFirst })
	h.register("b", func() error { called[1] = true; return errSecond })
	h.register("c", func() error { called[2] = true; return nil })

	// Invocation order is c, b, a (LIFO); the first non-nil error encountered is errSecond.
	err := h.run()
	if err != errSecond {
		t.Fatalf("run() err = %v, want %v", err, errSecond)
	}
	for i, c := range called {
		if !c {
			t.Fatalf("hook %d was not invoked", i)
		}
	}
}

// TestOnCloseHooks_NilReceiver ensures run() on a nil registry is a safe
// no-op. baseClient embedded in Conn/Tx does initialize the registry, but
// defensive nil-safety lets future constructors add the field without
// breaking Close().
func TestOnCloseHooks_NilReceiver(t *testing.T) {
	var h *onCloseHooks
	if err := h.run(); err != nil {
		t.Fatalf("run() on nil = %v, want nil", err)
	}
}

// TestOnCloseHooks_ConcurrentRegisterSameID hammers the registry with many
// goroutines re-registering under the same id. The registry must remain
// bounded and the surviving callback must still be invoked exactly once.
func TestOnCloseHooks_ConcurrentRegisterSameID(t *testing.T) {
	h := &onCloseHooks{}
	const id = "hot"
	const goroutines = 64
	const perG = 1_000

	var wg sync.WaitGroup
	wg.Add(goroutines)
	for g := 0; g < goroutines; g++ {
		go func() {
			defer wg.Done()
			for i := 0; i < perG; i++ {
				h.register(id, func() error { return nil })
			}
		}()
	}
	wg.Wait()

	if got := len(h.order); got != 1 {
		t.Fatalf("order length after concurrent storm = %d, want 1", got)
	}
}

// entraidLikeProvider mimics the exact semantics of
// github.com/redis/go-redis-entraid's StreamingCredentialsProvider relevant
// to issue #3772: it deduplicates subscriptions by listener pointer
// identity, and every call to Subscribe returns a FRESH UnsubscribeFunc
// closure that removes the listener by pointer match from the shared
// listeners slice. Two unsubs obtained for the same listener are therefore
// equivalent: the first one called removes the entry, any subsequent call
// is a safe no-op.
type entraidLikeProvider struct {
	mu         sync.Mutex
	listeners  []auth.CredentialsListener
	subscribeN atomic.Int32
	unsubCalls atomic.Int32
}

func (p *entraidLikeProvider) Subscribe(listener auth.CredentialsListener) (auth.Credentials, auth.UnsubscribeFunc, error) {
	p.subscribeN.Add(1)

	p.mu.Lock()
	already := false
	for _, l := range p.listeners {
		if l == listener {
			already = true
			break
		}
	}
	if !already {
		p.listeners = append(p.listeners, listener)
	}
	p.mu.Unlock()

	unsub := func() error {
		p.unsubCalls.Add(1)
		p.mu.Lock()
		defer p.mu.Unlock()
		for i, l := range p.listeners {
			if l == listener {
				p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
				return nil
			}
		}
		return nil
	}
	return auth.NewBasicCredentials("u", "p"), unsub, nil
}

func (p *entraidLikeProvider) listenerCount() int {
	p.mu.Lock()
	defer p.mu.Unlock()
	return len(p.listeners)
}

// stubCredentialsListener is a minimal auth.CredentialsListener used only
// for pointer-identity in the entraid-mimicking test.
type stubCredentialsListener struct{}

func (*stubCredentialsListener) OnNext(auth.Credentials) {}
func (*stubCredentialsListener) OnError(error)           {}

// TestInitConn_EntraidLike_NoLeakAcrossReinits is the targeted regression
// test for issue #3772 against the real-world StreamingCredentialsProvider
// behavior implemented by go-redis-entraid. It simulates N re-initializations
// on the same logical connection (i.e. the same CredentialsListener pointer,
// which is what streaming.Manager.Listener returns from its per-connId cache),
// replacing cn.SetOnClose with each new unsubscribe closure.
//
// Invariants the fix must uphold:
//  1. Subscribe dedups: the provider's listener list stays at size 1.
//  2. Calling only the MOST RECENT unsub fully removes the listener.
//  3. All prior (orphaned) unsubs are safe no-ops after that.
//  4. No registration remains on the provider after close.
func TestInitConn_EntraidLike_NoLeakAcrossReinits(t *testing.T) {
	const reinits = 1000

	provider := &entraidLikeProvider{}
	listener := &stubCredentialsListener{}

	var latestUnsub auth.UnsubscribeFunc
	orphaned := make([]auth.UnsubscribeFunc, 0, reinits-1)

	for i := 0; i < reinits; i++ {
		_, unsub, err := provider.Subscribe(listener)
		if err != nil {
			t.Fatalf("Subscribe #%d: %v", i, err)
		}
		if latestUnsub != nil {
			// Mirror the pool.Conn.SetOnClose behavior: the previous unsub
			// is dropped on the floor, only the latest one is retained.
			orphaned = append(orphaned, latestUnsub)
		}
		latestUnsub = unsub
	}

	if got := provider.listenerCount(); got != 1 {
		t.Fatalf("after %d Subscribes with same listener, listener count = %d, want 1", reinits, got)
	}
	if got := provider.subscribeN.Load(); got != int32(reinits) {
		t.Fatalf("Subscribe call count = %d, want %d", got, reinits)
	}

	// Only the latest unsub is invoked, matching the post-fix cn.onClose.
	if err := latestUnsub(); err != nil {
		t.Fatalf("latest unsub returned error: %v", err)
	}
	if got := provider.listenerCount(); got != 0 {
		t.Fatalf("listener count after latest unsub = %d, want 0", got)
	}

	// Every orphaned unsub must be a safe no-op (contract in auth.UnsubscribeFunc).
	for i, u := range orphaned {
		if err := u(); err != nil {
			t.Fatalf("orphaned unsub #%d returned error: %v", i, err)
		}
	}
	if got := provider.listenerCount(); got != 0 {
		t.Fatalf("listener count after orphaned unsubs = %d, want 0", got)
	}
}

// TestPubSubConn_PassesPersistedChannelsToNewConn is a regression test for
// https://github.com/redis/go-redis/issues/3806.
//
// ClusterClient.pubSub() installs a newConn closure that picks the target
// node by hash slot of channels[0]. Before the fix, PubSub.conn() only
// passed c.channels (regular SUBSCRIBE) into that closure — sharded
// SSubscribe channels stored in c.schannels were silently dropped.
//
// For an SSubscribe-only PubSub (the common ClusterClient.SSubscribe case)
// the channel list was empty on reconnect, so the closure fell through to
// nodes.Random(). The reconnected SSUBSCRIBE went to the wrong shard, the
// MOVED reply was never read, and the PubSub looked healthy while receiving
// no messages.
//
// This test exercises PubSub.conn() with a stub newConn that records the
// channel list it receives. The cluster routing layer is not under test
// here — the regression is whether the channel list reaches the closure.
func TestPubSubConn_PassesPersistedChannelsToNewConn(t *testing.T) {
	const (
		regularCh = "regular-ch"
		shardCh   = "{shardA}-ch1"
		freshCh   = "fresh-ch"
	)

	tests := []struct {
		name        string
		channels    map[string]struct{}
		schannels   map[string]struct{}
		newChannels []string
		wantIn      []string // strings that MUST be present in the captured slice
	}{
		{
			name:     "subscribe-only reconnect: existing channels forwarded",
			channels: map[string]struct{}{regularCh: {}},
			wantIn:   []string{regularCh},
		},
		{
			// The bug from #3806: SSubscribe-only reconnect dropped
			// c.schannels and reached newConn with an empty list.
			name:      "ssubscribe-only reconnect: schannels forwarded (#3806)",
			schannels: map[string]struct{}{shardCh: {}},
			wantIn:    []string{shardCh},
		},
		{
			name:        "initial ssubscribe: newChannels forwarded",
			newChannels: []string{shardCh},
			wantIn:      []string{shardCh},
		},
		{
			name:      "mixed subscribe + ssubscribe: both kinds forwarded",
			channels:  map[string]struct{}{regularCh: {}},
			schannels: map[string]struct{}{shardCh: {}},
			wantIn:    []string{regularCh, shardCh},
		},
		{
			name:        "newChannels appended after persisted ones",
			schannels:   map[string]struct{}{shardCh: {}},
			newChannels: []string{freshCh},
			wantIn:      []string{shardCh, freshCh},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			var captured []string
			stubErr := errors.New("stub: newConn does not create a real conn")

			ps := &PubSub{
				opt:       &Options{Addr: "stub:6379"},
				channels:  tt.channels,
				schannels: tt.schannels,
				newConn: func(_ context.Context, _ string, channels []string) (*pool.Conn, error) {
					captured = append([]string(nil), channels...)
					return nil, stubErr
				},
				closeConn: func(*pool.Conn) error { return nil },
			}

			ps.mu.Lock()
			_, err := ps.conn(context.Background(), tt.newChannels)
			ps.mu.Unlock()
			if !errors.Is(err, stubErr) {
				t.Fatalf("conn err = %v, want stub", err)
			}

			for _, want := range tt.wantIn {
				found := false
				for _, got := range captured {
					if got == want {
						found = true
						break
					}
				}
				if !found {
					t.Errorf("missing %q in channels passed to newConn (got %v); see #3806",
						want, captured)
				}
			}
		})
	}
}
