package server

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"

	eksv1alpha1 "github.com/crossplane-contrib/provider-aws/apis/eks/manualv1alpha1"
	gkecontainerv1beta1 "github.com/crossplane-contrib/provider-gcp/apis/container/v1beta1"
	xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
	"github.com/pingcap/log"
	aksv1beta1 "github.com/upbound/provider-azure/apis/containerservice/v1beta1"
	corev1 "k8s.io/api/core/v1"
	"sigs.k8s.io/controller-runtime/pkg/client"

	agentv2 "github.com/tidbcloud/observability/agent-control-server/api/v2"
	commonaws "github.com/tidbcloud/observability/agent-control-server/pkg/commontasks/aws"
	commonazure "github.com/tidbcloud/observability/agent-control-server/pkg/commontasks/azure"
	commongcp "github.com/tidbcloud/observability/agent-control-server/pkg/commontasks/gcp"
	"github.com/tidbcloud/observability/agent-control-server/pkg/consts"
)

// ListNodeGroupResponse is the response for GET /ops/nodegroups.
//
// "NodeGroup" is a logical term aligned with the regional-server NodeGroup proto,
// not tied to a specific cloud provider (AWS EKS NodeGroup, GCP GKE NodePool, etc.).
type ListNodeGroupResponse struct {
	NodeGroups []NodeGroup `json:"nodeGroups"`
	Total      int         `json:"total"`
}

// NodeGroup describes a single o11y agent's managed node pool version info.
// Version and count are read from the provider Crossplane CR, not from the
// agent annotation, to ensure accuracy during upgrades.
type NodeGroup struct {
	Kind    string `json:"kind"`    // "o11y-k8s-nodes" or "o11y-tidb-nodes"
	Count   int    `json:"count"`   // configured node count from the provider Crossplane CR
	Version string `json:"version"` // Kubernetes node version from the provider Crossplane CR
	Ready   bool   `json:"ready"`   // true when the provider Crossplane CR is ready

	AgentType             string `json:"agentType"`
	AgentName             string `json:"agentName"`
	KubernetesClusterName string `json:"kubernetesClusterName"`
	TenantID              string `json:"tenantId"`
	ProjectID             string `json:"projectId,omitempty"`
	ApplicationID         string `json:"applicationId"`
	NodeGroupName         string `json:"nodeGroupName"`
	Phase                 string `json:"phase"`
}

func (s *Server) routeListNodeGroups(w http.ResponseWriter, r *http.Request) {
	tenantID := r.URL.Query().Get("tenantId")
	projectID := r.URL.Query().Get("projectId")

	nodeGroups, err := s.listNodeGroups(r.Context(), tenantID, projectID)
	if err != nil {
		log.Error(fmt.Sprintf("failed to list nodegroups, tenantId=%s, projectId=%s, err=%v", tenantID, projectID, err))
		s.respondWithError(w, http.StatusInternalServerError, err)
		return
	}

	s.respondWithData(w, http.StatusOK, "success", ListNodeGroupResponse{
		NodeGroups: nodeGroups,
		Total:      len(nodeGroups),
	})
}

func (s *Server) listNodeGroups(ctx context.Context, tenantID, projectID string) ([]NodeGroup, error) {
	var nodegroups []NodeGroup

	k8sNodes, err := s.listO11yK8sNodeGroups(ctx, tenantID, projectID)
	if err != nil {
		return nil, fmt.Errorf("failed to list o11yk8s nodegroups: %w", err)
	}
	nodegroups = append(nodegroups, k8sNodes...)

	tidbNodes, err := s.listO11yTiDBNodeGroups(ctx, tenantID, projectID)
	if err != nil {
		return nil, fmt.Errorf("failed to list o11ytidb nodegroups: %w", err)
	}
	nodegroups = append(nodegroups, tidbNodes...)

	return nodegroups, nil
}

func (s *Server) listO11yK8sNodeGroups(ctx context.Context, tenantID, projectID string) ([]NodeGroup, error) {
	var agents agentv2.O11yK8sAgentList
	if err := s.client.List(ctx, &agents); err != nil {
		return nil, err
	}

	nodegroups := make([]NodeGroup, 0, len(agents.Items))
	for _, agent := range agents.Items {
		o11yCfg := agent.Spec.O11y
		if tenantID != "" && o11yCfg.TenantID != tenantID {
			continue
		}
		if projectID != "" && o11yCfg.ProjectID != projectID {
			continue
		}

		nodeGroupName := fmt.Sprintf("tidbcloud-o11y-k8s-%s", o11yCfg.ProjectID)
		desiredSpec := agent.GetAnnotations()[consts.AnnotationKeyO11yDesiredSpec]
		state := s.getNodeGroupState(ctx, map[string]string{
			"o11y.tidbcloud.com/component": "o11y",
			"o11y.tidbcloud.com/project":   o11yCfg.ProjectID,
		}, nodeGroupName, desiredSpec)
		nodegroups = append(nodegroups, NodeGroup{
			Kind:                  "o11y-k8s-nodes",
			Count:                 state.Count,
			Version:               state.Version,
			Ready:                 state.Ready,
			AgentType:             consts.AgentO11yK8s,
			AgentName:             agent.Name,
			KubernetesClusterName: agent.Spec.KubernetesClusterName,
			TenantID:              o11yCfg.TenantID,
			ProjectID:             o11yCfg.ProjectID,
			ApplicationID:         o11yCfg.ApplicationID,
			NodeGroupName:         nodeGroupName,
			Phase:                 agent.Status.Phase,
		})
	}
	return nodegroups, nil
}

func (s *Server) listO11yTiDBNodeGroups(ctx context.Context, tenantID, projectID string) ([]NodeGroup, error) {
	var agents agentv2.O11yTiDBAgentList
	if err := s.client.List(ctx, &agents); err != nil {
		return nil, err
	}

	nodegroups := make([]NodeGroup, 0, len(agents.Items))
	for _, agent := range agents.Items {
		o11yCfg := agent.Spec.O11y
		if tenantID != "" && o11yCfg.TenantID != tenantID {
			continue
		}
		if projectID != "" && o11yCfg.ProjectID != projectID {
			continue
		}

		nodeGroupName := fmt.Sprintf("tidbcloud-o11y-tidb-%s", o11yCfg.ApplicationID)
		desiredSpec := agent.GetAnnotations()[consts.AnnotationKeyO11yDesiredSpec]
		state := s.getNodeGroupState(ctx, map[string]string{
			"o11y.tidbcloud.com/component": "o11y",
			"o11y.tidbcloud.com/cluster":   o11yCfg.ApplicationID,
		}, nodeGroupName, desiredSpec)
		// When the Agent CR is already in the Hibernated phase during an upgrade,
		// return the desired state instead of the actual state to avoid blocking
		// the regional-server upgrade. Once the cluster leaves the paused state,
		// NodeGroup resyncs it to the correct version.
		if agent.Status.Phase == agentv2.O11yAgentPhaseHibernated {
			state.Count = 1
			state.Version = agent.GetAnnotations()[consts.AnnotationKeyO11yNodeVersion]
			state.Ready = true
		}
		nodegroups = append(nodegroups, NodeGroup{
			Kind:                  "o11y-tidb-nodes",
			Count:                 state.Count,
			Version:               state.Version,
			Ready:                 state.Ready,
			AgentType:             consts.AgentO11yTidb,
			AgentName:             agent.Name,
			KubernetesClusterName: agent.Spec.KubernetesClusterName,
			TenantID:              o11yCfg.TenantID,
			ProjectID:             o11yCfg.ProjectID,
			ApplicationID:         o11yCfg.ApplicationID,
			NodeGroupName:         nodeGroupName,
			Phase:                 agent.Status.Phase,
		})
	}
	return nodegroups, nil
}

// nodeGroupState holds the observed state read from a Crossplane NodeGroup CR.
type nodeGroupState struct {
	Version string
	Count   int
	Ready   bool
}

// getNodeGroupState reads version and count from the provider Crossplane CR
// matching the given labels.
//
// NodeGroups are labeled by the o11y controllers at creation with
// o11y.tidbcloud.com/component=o11y plus project= or cluster=. A label lookup
// is stable across autoresourcing spec-name suffixes (e.g. -medium, -large),
// where a bare Get-by-name lookup is not. This is the source of truth — it
// reflects the actual deployed state, not the annotation on the agent CR
// (which may be ahead of reconciliation during upgrades).
//
// Exactly one match is expected per agent. During a transient autoresourcing
// spec-transition window both the previous- and desired-spec NodeGroups may
// co-exist; baseName plus desiredSpec identifies the desired one (e.g.
// baseName + "-large"). If the expected name is not present, the first item
// is used and a warning is logged, but the request is not failed.
func (s *Server) getNodeGroupState(ctx context.Context, labels map[string]string, baseName, desiredSpec string) nodeGroupState {
	switch s.config.O11yConfig.Vendor {
	case consts.VendorTypeGCP:
		return s.getGCPNodePoolState(ctx, labels, baseName, desiredSpec)
	case consts.VendorTypeAzure:
		return s.getAzureNodePoolState(ctx, labels, baseName, desiredSpec)
	default:
		return s.getAWSNodeGroupState(ctx, labels, baseName, desiredSpec)
	}
}

func (s *Server) getAWSNodeGroupState(ctx context.Context, labels map[string]string, baseName, desiredSpec string) nodeGroupState {
	var list eksv1alpha1.NodeGroupList
	if err := s.client.List(ctx, &list, client.MatchingLabels(labels)); err != nil {
		log.Warn(fmt.Sprintf("getAWSNodeGroupState: failed to list nodegroups for labels %v: %v", labels, err))
		return nodeGroupState{}
	}
	if len(list.Items) == 0 {
		return nodeGroupState{}
	}

	ng := pickNodeGroup(list.Items, baseName, desiredSpec)
	if len(list.Items) > 1 {
		log.Warn(fmt.Sprintf("getAWSNodeGroupState: %d nodegroups match labels %v (likely autoresourcing spec transition); using %s", len(list.Items), labels, ng.Name))
	}

	var state nodeGroupState

	state.Ready = ng.Status.AtProvider.Status == eksv1alpha1.NodeGroupStatusActive

	// Version: prefer status (observed), fall back to spec (desired).
	if v := ng.Status.AtProvider.Version; v != "" {
		state.Version = v
	} else if ng.Spec.ForProvider.Version != nil {
		state.Version = *ng.Spec.ForProvider.Version
	}

	// Count: prefer spec desired size, fall back to status.
	if ng.Spec.ForProvider.ScalingConfig != nil && ng.Spec.ForProvider.ScalingConfig.DesiredSize != nil {
		state.Count = int(*ng.Spec.ForProvider.ScalingConfig.DesiredSize)
	} else if ng.Status.AtProvider.ScalingConfig.DesiredSize != nil {
		state.Count = int(*ng.Status.AtProvider.ScalingConfig.DesiredSize)
	}

	return state
}

func (s *Server) getGCPNodePoolState(ctx context.Context, labels map[string]string, baseName, desiredSpec string) nodeGroupState {
	var list gkecontainerv1beta1.NodePoolList
	if err := s.client.List(ctx, &list, client.MatchingLabels(labels)); err != nil {
		log.Warn(fmt.Sprintf("getGCPNodePoolState: failed to list nodepools for labels %v: %v", labels, err))
		return nodeGroupState{}
	}
	if len(list.Items) == 0 {
		return nodeGroupState{}
	}

	nodePool := pickGCPNodePool(list.Items, baseName, desiredSpec)
	if len(list.Items) > 1 {
		log.Warn(fmt.Sprintf("getGCPNodePoolState: %d nodepools match labels %v (likely autoresourcing spec transition); using %s", len(list.Items), labels, nodePool.Name))
	}

	state := nodeGroupState{
		Ready: nodePool.Status.AtProvider.Status == gkecontainerv1beta1.NodePoolStateRunning,
	}
	if nodePool.Status.AtProvider.Status == "" {
		state.Ready = nodePool.Status.GetCondition(xpv1.TypeReady).Status == corev1.ConditionTrue
	}
	if nodePool.Spec.ForProvider.Version != nil {
		state.Version = *nodePool.Spec.ForProvider.Version
	}
	// GCP Crossplane NodePool status does not expose an observed node version or
	// live replica count, so we read from spec.
	if nodePool.Spec.ForProvider.InitialNodeCount != nil {
		state.Count = int(*nodePool.Spec.ForProvider.InitialNodeCount)
	}

	return state
}

func (s *Server) getAzureNodePoolState(ctx context.Context, labels map[string]string, baseName, desiredSpec string) nodeGroupState {
	var list aksv1beta1.KubernetesClusterNodePoolList
	if err := s.client.List(ctx, &list, client.MatchingLabels(labels)); err != nil {
		log.Warn(fmt.Sprintf("getAzureNodePoolState: failed to list nodepools for labels %v: %v", labels, err))
		return nodeGroupState{}
	}
	if len(list.Items) == 0 {
		return nodeGroupState{}
	}

	nodePool := pickAzureNodePool(list.Items, baseName, desiredSpec)
	if len(list.Items) > 1 {
		log.Warn(fmt.Sprintf("getAzureNodePoolState: %d nodepools match labels %v (likely autoresourcing spec transition); using %s", len(list.Items), labels, nodePool.Name))
	}

	state := nodeGroupState{
		// AKS NodePool has no Status.AtProvider.Status enum; the Ready condition
		// is the same signal checkNodePool uses (pkg/commontasks/azure/nodepool.go).
		Ready: nodePool.Status.GetCondition(xpv1.TypeReady).Status == corev1.ConditionTrue,
	}

	// Version: prefer status (observed), fall back to spec (desired).
	if nodePool.Status.AtProvider.OrchestratorVersion != nil {
		state.Version = *nodePool.Status.AtProvider.OrchestratorVersion
	} else if nodePool.Spec.ForProvider.OrchestratorVersion != nil {
		state.Version = *nodePool.Spec.ForProvider.OrchestratorVersion
	}

	// Count: prefer spec desired size, fall back to status. Under autoscaling
	// the live count drifts, so spec is the stable signal.
	if nodePool.Spec.ForProvider.NodeCount != nil {
		state.Count = int(*nodePool.Spec.ForProvider.NodeCount)
	} else if nodePool.Status.AtProvider.NodeCount != nil {
		state.Count = int(*nodePool.Status.AtProvider.NodeCount)
	}

	return state
}

// pickNodeGroup returns the NodeGroup matching the desired spec, falling back
// to the first item if no exact match is found.
//
// NodeGroup names follow baseName for the default spec (e.g.
// "tidbcloud-o11y-k8s-<project>") and baseName + "-" + specName otherwise
// (e.g. "...-medium", "...-large"). BOTTLEROCKET NodeGroups append a
// "-bottlerocket" suffix (see commonaws.NameSuffix); during AMIType migration
// both the suffixed (new) and unsuffixed (legacy) NGs may coexist, so the
// suffixed name is preferred when present.
func pickNodeGroup(items []eksv1alpha1.NodeGroup, baseName, desiredSpec string) *eksv1alpha1.NodeGroup {
	specSuffix := ""
	if desiredSpec != "" && desiredSpec != "default" {
		specSuffix = "-" + desiredSpec
	}
	// Preference order: <base><spec>-bottlerocket, <base><spec>, <base>-bottlerocket, <base>.
	// Suffixed candidates are tried first because BOTTLEROCKET NGs (post-AL2
	// migration) carry the suffix; if no suffixed NG exists we fall back to
	// the unsuffixed names used by native BOTTLEROCKET and pre-migration AL2.
	candidates := []string{
		baseName + specSuffix + commonaws.BottlerocketSuffix,
		baseName + specSuffix,
		baseName + commonaws.BottlerocketSuffix,
		baseName,
	}
	for _, name := range candidates {
		for i := range items {
			if items[i].Name == name {
				return &items[i]
			}
		}
	}
	return &items[0]
}

func pickGCPNodePool(items []gkecontainerv1beta1.NodePool, baseName, desiredSpec string) *gkecontainerv1beta1.NodePool {
	candidates := []string{
		commongcp.DedicatedNodePoolName(baseName, desiredSpec),
		commongcp.DedicatedNodePoolName(baseName, "default"),
	}
	for _, name := range candidates {
		for i := range items {
			if items[i].Name == name {
				return &items[i]
			}
		}
	}
	log.Warn(fmt.Sprintf("pickGCPNodePool: no nodepool matched candidates %v for baseName %q, desiredSpec %q; falling back to %s", candidates, baseName, desiredSpec, items[0].Name))
	return &items[0]
}

func pickAzureNodePool(items []aksv1beta1.KubernetesClusterNodePool, baseName, desiredSpec string) *aksv1beta1.KubernetesClusterNodePool {
	candidates := []string{
		commonazure.DedicatedNodePoolName(baseName, desiredSpec),
		commonazure.DedicatedNodePoolName(baseName, "default"),
	}
	for _, name := range candidates {
		for i := range items {
			if items[i].Name == name {
				return &items[i]
			}
		}
	}
	log.Warn(fmt.Sprintf("pickAzureNodePool: no nodepool matched candidates %v for baseName %q, desiredSpec %q; falling back to %s", candidates, baseName, desiredSpec, items[0].Name))
	return &items[0]
}

type NodeGroupsUpgradeRequest struct {
	Kind      string `json:"kind"` // "o11y-k8s-nodes" or "o11y-tidb-nodes"
	Version   string `json:"version"`
	AgentName string `json:"agentName"`
}

type NodeGroupsUpgradeResponse struct {
	Kind           string `json:"kind"`
	AgentType      string `json:"agentType"`
	AgentName      string `json:"agentName"`
	CurrentVersion string `json:"currentVersion"`
	TargetVersion  string `json:"targetVersion"`
}

func (s *Server) routePostNodeGroupsUpgrade(w http.ResponseWriter, r *http.Request) {
	var req NodeGroupsUpgradeRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		msg := fmt.Sprintf("nodegroup upgrade: failed to decode request, err=%v", err)
		log.Warn(msg)
		s.respond(w, http.StatusBadRequest, msg)
		return
	}
	if req.Version == "" || req.AgentName == "" || req.Kind == "" {
		msg := fmt.Sprintf("nodegroup upgrade: version, agentName and kind are required, got kind=%s, agentName=%s, version=%s", req.Kind, req.AgentName, req.Version)
		log.Warn(msg)
		s.respond(w, http.StatusBadRequest, msg)
		return
	}

	agentType := targetToAgentType(req.Kind)
	if agentType == "" {
		msg := fmt.Sprintf("nodegroup upgrade: invalid target %s", req.Kind)
		log.Warn(msg)
		s.respond(w, http.StatusBadRequest, msg)
		return
	}

	obj, err := s.getAgentObject(r.Context(), agentType, req.AgentName)
	if err != nil {
		msg := fmt.Sprintf("nodegroup upgrade: agent not found, kind=%s, agentName=%s, err=%v", req.Kind, req.AgentName, err)
		log.Warn(msg)
		s.respond(w, http.StatusNotFound, msg)
		return
	}

	phase := getAgentPhase(obj)
	if phase != agentv2.O11yAgentPhaseNormal && phase != agentv2.O11yAgentPhaseHibernated {
		msg := fmt.Sprintf("nodegroup upgrade: agent %s/%s is in %q phase", req.Kind, req.AgentName, phase)
		log.Warn(msg)
		s.respond(w, http.StatusConflict, msg)
		return
	}

	currentVersion := obj.GetAnnotations()[consts.AnnotationKeyO11yNodeVersion]
	if currentVersion == req.Version {
		msg := fmt.Sprintf("nodegroup upgrade: agent %s/%s is already at version %s, no upgrade needed", req.Kind, req.AgentName, currentVersion)
		log.Info(msg)
		s.respondWithData(w, http.StatusOK, msg, NodeGroupsUpgradeResponse{
			AgentType:      agentType,
			AgentName:      req.AgentName,
			Kind:           req.Kind,
			CurrentVersion: currentVersion,
			TargetVersion:  req.Version,
		})
		return
	}

	annotations := obj.GetAnnotations()
	if annotations == nil {
		annotations = make(map[string]string)
	}
	annotations[consts.AnnotationKeyO11yNodeVersion] = req.Version
	annotations[consts.AnnotationKeyO11yNodeReleaseVersion] = req.Version
	obj.SetAnnotations(annotations)

	if err := s.client.Update(r.Context(), obj); err != nil {
		msg := fmt.Sprintf("nodegroup upgrade: failed to update annotation, kind=%s, agentName=%s, err=%v", req.Kind, req.AgentName, err)
		log.Error(msg)
		s.respond(w, http.StatusInternalServerError, msg)
		return
	}

	msg := fmt.Sprintf("nodegroup upgrade: agent %s/%s version %s -> %s", req.Kind, req.AgentName, currentVersion, req.Version)
	log.Info(msg)
	s.respondWithData(w, http.StatusOK, msg, NodeGroupsUpgradeResponse{
		AgentType:      agentType,
		AgentName:      req.AgentName,
		Kind:           req.Kind,
		CurrentVersion: currentVersion,
		TargetVersion:  req.Version,
	})
}

func targetToAgentType(target string) string {
	switch target {
	case "o11y-k8s-nodes":
		return consts.AgentO11yK8s
	case "o11y-tidb-nodes":
		return consts.AgentO11yTidb
	default:
		return ""
	}
}

func getAgentPhase(obj client.Object) string {
	switch a := obj.(type) {
	case *agentv2.O11yK8sAgent:
		return a.Status.Phase
	case *agentv2.O11yTiDBAgent:
		return a.Status.Phase
	default:
		return ""
	}
}
