package az

import (
	"context"
	"strings"
	"testing"
	"time"

	opv1alpha1 "github.com/pingcap/tidb-operator/pkg/apis/pingcap/v1alpha1"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"

	cloudv1 "github.com/tidbcloud/infra-api/api/v1"
	caapiv1 "github.com/tidbcloud/infra-provider/api/tidbcloud/cluster-agent/v1"
	apicommon "github.com/tidbcloud/infra-provider/api/tidbcloud/common"
	"github.com/tidbcloud/infra-provider/pkg/consts"
)

func TestParsePDStores(t *testing.T) {
	t.Parallel()
	stdout := `{
  "count": 2,
  "stores": [
    {
      "store": {
        "id": 1,
        "state_name": "Offline",
        "labels": [{"key": "zone", "value": "us-west-2a"}]
      }
    },
    {
      "store": {
        "id": 2,
        "state_name": "Up",
        "labels": [{"key": "zone", "value": "us-west-2b"}]
      }
    }
  ]
}`
	stores, err := parsePDStores(stdout)
	if err != nil {
		t.Fatalf("parsePDStores() error = %v", err)
	}
	if len(stores) != 2 {
		t.Fatalf("parsePDStores() len = %d, want 2", len(stores))
	}
	if stores[0].ID != 1 || stores[0].StateName != "Offline" || stores[0].labelValue("zone") != "us-west-2a" {
		t.Fatalf("unexpected store[0]: %+v", stores[0])
	}
	if stores[1].ID != 2 || stores[1].StateName != "Up" || stores[1].labelValue("zone") != "us-west-2b" {
		t.Fatalf("unexpected store[1]: %+v", stores[1])
	}
}

func TestParsePDStoresNonJSON(t *testing.T) {
	t.Parallel()
	_, err := parsePDStores("Failed to get stores: connection refused")
	if err == nil {
		t.Fatal("expected error for non-json stdout")
	}
	if !strings.Contains(err.Error(), "Failed to get stores") {
		t.Fatalf("error should include stdout, got: %v", err)
	}
}

func TestPDClientServiceURL(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name string
		host string
		port uint32
		want string
	}{
		{
			name: "peer host to client service",
			host: "db-pd-0.db-pd-peer.tidb123.svc",
			port: 2379,
			want: "https://db-pd.tidb123.svc:2379",
		},
		{
			name: "fallback to raw host",
			host: "10.0.0.1",
			port: 2379,
			want: "https://10.0.0.1:2379",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := pdClientServiceURL(&caapiv1.GetClusterTopologyResp_PDNodeInfo{
				Host: tt.host,
				Port: tt.port,
			})
			if got != tt.want {
				t.Fatalf("pdClientServiceURL() = %q, want %q", got, tt.want)
			}
		})
	}
}

func TestParseGracefulTimeout(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name string
		raw  string
		want time.Duration
	}{
		{name: "empty uses default", raw: "", want: defaultGracefulTimeout},
		{name: "custom duration", raw: "5m", want: 5 * time.Minute},
		{name: "invalid uses default", raw: "invalid", want: defaultGracefulTimeout},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := parseGracefulTimeout(tt.raw); got != tt.want {
				t.Fatalf("parseGracefulTimeout() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestComponentTypeName(t *testing.T) {
	t.Parallel()
	if got := componentTypeName(apicommon.ComponentType_PD); got != "pd" {
		t.Fatalf("componentTypeName(PD) = %q", got)
	}
	if got := componentTypeName(apicommon.ComponentType_TIKV); got != "tikv" {
		t.Fatalf("componentTypeName(TIKV) = %q", got)
	}
}

func TestIsTargetComponent(t *testing.T) {
	t.Parallel()
	targets := []apicommon.ComponentType{apicommon.ComponentType_PD, apicommon.ComponentType_TIKV}
	if !isTargetComponent(apicommon.ComponentType_PD, targets) {
		t.Fatal("expected pd to be target component")
	}
	if isTargetComponent(apicommon.ComponentType_TIPROXY, targets) {
		t.Fatal("expected tiproxy not to be target component")
	}
}

func TestResolveGroups(t *testing.T) {
	t.Parallel()
	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Name: "c1",
			Annotations: map[string]string{
				cloudv1.DefaultLoadBalancerIDAnnotationKey: "default-gid",
			},
		},
		Spec: cloudv1.ClusterSpec{
			ExtraGroups: []cloudv1.ExtraGroupSpec{
				{Name: "analytics", TiDB: cloudv1.TiDB{Replicas: 1}},
			},
		},
	}

	t.Run("empty means all", func(t *testing.T) {
		groups, err := resolveGroups(cluster, nil)
		if err != nil {
			t.Fatal(err)
		}
		if len(groups) != 2 {
			t.Fatalf("want 2 groups, got %d", len(groups))
		}
		if !groups[0].IsDefault() || groups[0].ID() != "default-gid" {
			t.Fatalf("want default first, got %+v", groups[0])
		}
		if groups[1].IsDefault() || groups[1].ID() != "analytics" {
			t.Fatalf("want analytics second, got %+v", groups[1])
		}
		if groups[0].ClusterLabel() != "c1" || groups[1].ClusterLabel() != "c1-analytics" {
			t.Fatalf("unexpected cluster labels: %s %s", groups[0].ClusterLabel(), groups[1].ClusterLabel())
		}
	})

	t.Run("empty preserves ExtraGroups order", func(t *testing.T) {
		multi := cluster.DeepCopy()
		multi.Spec.ExtraGroups = []cloudv1.ExtraGroupSpec{
			{Name: "analytics", TiDB: cloudv1.TiDB{Replicas: 1}},
			{Name: "reporting", TiDB: cloudv1.TiDB{Replicas: 1}},
			{Name: "batch", TiDB: cloudv1.TiDB{Replicas: 1}},
		}
		groups, err := resolveGroups(multi, nil)
		if err != nil {
			t.Fatal(err)
		}
		want := []string{"default-gid", "analytics", "reporting", "batch"}
		got := groupIDsOf(groups)
		if len(got) != len(want) {
			t.Fatalf("want %v, got %v", want, got)
		}
		for i := range want {
			if got[i] != want[i] {
				t.Fatalf("want order %v, got %v", want, got)
			}
		}
	})

	t.Run("extra only has sql-plane components", func(t *testing.T) {
		groups, err := resolveGroups(cluster, []string{"analytics"})
		if err != nil {
			t.Fatal(err)
		}
		if len(groups) != 1 || groups[0].IsDefault() {
			t.Fatalf("want single extra group, got %v", groupIDsOf(groups))
		}
		if findDefaultGroup(groups) != nil {
			t.Fatal("expected no default group")
		}
		if len(groups[0].Components()) != 2 {
			t.Fatalf("want tidb+tiproxy only, got %v", groups[0].Components())
		}
		if groups[0].MatchesNode(apicommon.ComponentType_PD, "c1") {
			t.Fatal("extra group must not match PD")
		}
		if !groups[0].MatchesNode(apicommon.ComponentType_TIDB, "c1-analytics") {
			t.Fatal("extra group should match its tidb label")
		}
	})

	t.Run("default includes shared", func(t *testing.T) {
		groups, err := resolveGroups(cluster, []string{"default-gid"})
		if err != nil {
			t.Fatal(err)
		}
		dg := findDefaultGroup(groups)
		if dg == nil {
			t.Fatal("expected default group")
		}
		if !dg.MatchesNode(apicommon.ComponentType_PD, "") {
			t.Fatal("default group should match PD")
		}
		if len(dg.Components()) != 5 {
			t.Fatalf("want all components, got %v", dg.Components())
		}
	})

	t.Run("extra group namer has tidb-namespace for ASG selector", func(t *testing.T) {
		groups, err := resolveGroups(cluster, []string{"analytics"})
		if err != nil {
			t.Fatal(err)
		}
		nm := groups[0].Namer()
		if nm.GetTiDBNamespace() != "tidbc1" {
			t.Fatalf("want tidb-namespace tidbc1, got %q", nm.GetTiDBNamespace())
		}
		if nm.GetClusterName() != "c1-analytics" {
			t.Fatalf("want cluster name c1-analytics, got %q", nm.GetClusterName())
		}
		sel := nm.ClusterSelector().Selector.String()
		if !strings.Contains(sel, "tidb-namespace=tidbc1") {
			t.Fatalf("want tidb-namespace=tidbc1 in selector, got %s", sel)
		}
		if !strings.Contains(sel, "tidbcloud-cluster=c1-analytics") {
			t.Fatalf("want tidbcloud-cluster=c1-analytics in selector, got %s", sel)
		}
	})

	t.Run("unknown id", func(t *testing.T) {
		_, err := resolveGroups(cluster, []string{"nope"})
		if err == nil {
			t.Fatal("expected error")
		}
	})
}

func TestMergeNames(t *testing.T) {
	t.Parallel()
	got := mergeNames("pod-a,pod-b", []string{"pod-b", "pod-c"})
	want := []string{"pod-a", "pod-b", "pod-c"}
	if len(got) != len(want) {
		t.Fatalf("mergeNames() = %v, want %v", got, want)
	}
	for i := range want {
		if got[i] != want[i] {
			t.Fatalf("mergeNames() = %v, want %v", got, want)
		}
	}
	if joinNames([]string{"b", "a"}) != "a,b" {
		t.Fatal("joinNames() unexpected result")
	}
}

type recordingDeleteClient struct {
	client.Client
	deleted     bool
	gracePeriod *int64
}

func (c *recordingDeleteClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
	c.deleted = true
	do := &client.DeleteOptions{}
	do.ApplyOptions(opts)
	c.gracePeriod = do.GracePeriodSeconds
	return c.Client.Delete(ctx, obj, opts...)
}

func TestDeletePod(t *testing.T) {
	t.Parallel()
	ctx := context.Background()
	now := metav1.Now()
	ns := "tidbc1"

	newTerminatingPod := func(name string) *corev1.Pod {
		return &corev1.Pod{
			ObjectMeta: metav1.ObjectMeta{
				Name:              name,
				Namespace:         ns,
				DeletionTimestamp: &now,
				Finalizers:        []string{"test.finalizer"},
			},
		}
	}

	t.Run("skip graceful delete when already terminating", func(t *testing.T) {
		t.Parallel()
		cli := &recordingDeleteClient{Client: fake.NewClientBuilder().WithObjects(newTerminatingPod("tikv-0")).Build()}
		if err := deletePod(ctx, cli, ns, "tikv-0", false); err != nil {
			t.Fatalf("deletePod() error = %v", err)
		}
		if cli.deleted {
			t.Fatal("expected graceful delete to skip already terminating pod")
		}
	})

	t.Run("force delete proceeds when already terminating", func(t *testing.T) {
		t.Parallel()
		cli := &recordingDeleteClient{Client: fake.NewClientBuilder().WithObjects(newTerminatingPod("tikv-0")).Build()}
		if err := deletePod(ctx, cli, ns, "tikv-0", true); err != nil {
			t.Fatalf("deletePod() error = %v", err)
		}
		if !cli.deleted {
			t.Fatal("expected force delete to issue Delete even if pod is terminating")
		}
		if cli.gracePeriod == nil || *cli.gracePeriod != 0 {
			t.Fatalf("expected GracePeriodSeconds(0), got %v", cli.gracePeriod)
		}
	})

	t.Run("skip when not found", func(t *testing.T) {
		t.Parallel()
		cli := fake.NewClientBuilder().Build()
		if err := deletePod(ctx, cli, ns, "missing", true); err != nil {
			t.Fatalf("deletePod() error = %v", err)
		}
	})
}

func TestIsPodShutdownComplete(t *testing.T) {
	t.Parallel()
	now := metav1.Now()
	tests := []struct {
		name string
		pod  *corev1.Pod
		want bool
	}{
		{
			name: "terminating pod is not complete",
			pod: &corev1.Pod{
				ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
				Status:     corev1.PodStatus{Phase: corev1.PodRunning},
			},
			want: false,
		},
		{
			name: "running pod is not complete",
			pod: &corev1.Pod{
				Status: corev1.PodStatus{Phase: corev1.PodRunning},
			},
			want: false,
		},
		{
			name: "pending unscheduled replacement pod is complete",
			pod: &corev1.Pod{
				Status: corev1.PodStatus{Phase: corev1.PodPending},
			},
			want: true,
		},
		{
			name: "pending pod scheduled to node is not complete",
			pod: &corev1.Pod{
				Spec:   corev1.PodSpec{NodeName: "node-1"},
				Status: corev1.PodStatus{Phase: corev1.PodPending},
			},
			want: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := isPodShutdownComplete(tt.pod); got != tt.want {
				t.Fatalf("isPodShutdownComplete() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestEnsureComponentPodsRunning(t *testing.T) {
	t.Parallel()

	newPod := func(name string, comp apicommon.ComponentType, phase string) *caapiv1.Pod {
		return &caapiv1.Pod{
			K8SMetadata:   &caapiv1.K8SMetadata{Name: name},
			ComponentType: comp,
			Phase:         phase,
		}
	}

	tests := []struct {
		name    string
		pods    []*caapiv1.Pod
		wantErr string
		want    []string
	}{
		{
			name: "all target pods running",
			pods: []*caapiv1.Pod{
				newPod("tikv-0", apicommon.ComponentType_TIKV, "Running"),
				newPod("pd-0", apicommon.ComponentType_PD, "Running"),
				newPod("ticdc-0", apicommon.ComponentType_TICDC, "Pending"),
			},
			want: []string{"tikv-0", "pd-0"},
		},
		{
			name: "pending target pod blocks success",
			pods: []*caapiv1.Pod{
				newPod("tikv-0", apicommon.ComponentType_TIKV, "Running"),
				newPod("tikv-1", apicommon.ComponentType_TIKV, "Pending"),
			},
			wantErr: "waiting for Running",
		},
		{
			name:    "no target pods",
			pods:    []*caapiv1.Pod{newPod("ticdc-0", apicommon.ComponentType_TICDC, "Running")},
			wantErr: "no target component pods found",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got, err := ensureComponentPodsRunning(tt.pods)
			if tt.wantErr != "" {
				if err == nil {
					t.Fatalf("expected error containing %q, got nil", tt.wantErr)
				}
				if !strings.Contains(err.Error(), tt.wantErr) {
					t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
				}
				return
			}
			if err != nil {
				t.Fatalf("ensureComponentPodsRunning() error = %v", err)
			}
			if len(got) != len(tt.want) {
				t.Fatalf("running pods = %v, want %v", got, tt.want)
			}
			gotSet := make(map[string]struct{}, len(got))
			for _, name := range got {
				gotSet[name] = struct{}{}
			}
			for _, name := range tt.want {
				if _, ok := gotSet[name]; !ok {
					t.Fatalf("running pods = %v, want include %s", got, name)
				}
			}
		})
	}
}

func TestVerifyGroupTCReady(t *testing.T) {
	t.Parallel()

	scheme := runtime.NewScheme()
	if err := opv1alpha1.AddToScheme(scheme); err != nil {
		t.Fatal(err)
	}

	cluster := &cloudv1.Cluster{
		ObjectMeta: metav1.ObjectMeta{
			Name: "c1",
			Annotations: map[string]string{
				cloudv1.DefaultLoadBalancerIDAnnotationKey: "default-gid",
			},
		},
		Spec: cloudv1.ClusterSpec{
			ExtraGroups: []cloudv1.ExtraGroupSpec{
				{Name: "analytics", TiDB: cloudv1.TiDB{Replicas: 1}},
			},
		},
	}
	groups, err := resolveGroups(cluster, nil)
	if err != nil {
		t.Fatal(err)
	}
	namespace := cluster.PrefixedId()

	readyTC := func(name string) *opv1alpha1.TidbCluster {
		return &opv1alpha1.TidbCluster{
			ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
			Status: opv1alpha1.TidbClusterStatus{
				Conditions: []opv1alpha1.TidbClusterCondition{{
					Type:   opv1alpha1.TidbClusterReady,
					Status: corev1.ConditionTrue,
				}},
			},
		}
	}

	t.Run("all group tcs ready", func(t *testing.T) {
		var extra Group
		for _, g := range groups {
			if !g.IsDefault() {
				extra = g
				break
			}
		}
		cli := fake.NewClientBuilder().WithScheme(scheme).
			WithObjects(readyTC(consts.ClusterPrefix), readyTC(extra.Namer().GetTiDBName())).
			Build()
		for _, g := range groups {
			if err := verifyGroupTCReady(context.Background(), cli, namespace, g); err != nil {
				t.Fatalf("verifyGroupTCReady(%s) error = %v", g.ID(), err)
			}
		}
	})

	t.Run("not ready blocks", func(t *testing.T) {
		cli := fake.NewClientBuilder().WithScheme(scheme).
			WithObjects(&opv1alpha1.TidbCluster{
				ObjectMeta: metav1.ObjectMeta{Name: consts.ClusterPrefix, Namespace: namespace},
			}).
			Build()
		err := verifyGroupTCReady(context.Background(), cli, namespace, findDefaultGroup(groups))
		if err == nil || !strings.Contains(err.Error(), "not ready") {
			t.Fatalf("error = %v, want not ready", err)
		}
	})
}
