//go:generate ${GOBIN}/mockgen -destination client_mock.go -package=devops ${GO_MODULE}/internal/integrations/devops Client

package devops

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/go-resty/resty/v2"
	"github.com/tidbcloud/aisupport/services/ticket-manager/config"
)

type Client interface {
	GetTenant(ctx context.Context, tenantID string) (*Tenant, error)
	GetCluster(ctx context.Context, clusterID string) (*Cluster, error)
	ListOperations(ctx context.Context, clusterID string, window TimeWindow) ([]*Operation, error)
	ListConfigChanges(ctx context.Context, clusterID, clusterType string, window TimeWindow) ([]*ConfigChange, error)
	FrontendURL() string
}

type TimeWindow struct {
	Start time.Time
	End   time.Time
}

func (w TimeWindow) Contains(value *time.Time) bool {
	if value == nil || value.IsZero() {
		return false
	}
	t := value.UTC()
	if !w.Start.IsZero() && t.Before(w.Start.UTC()) {
		return false
	}
	if !w.End.IsZero() && t.After(w.End.UTC()) {
		return false
	}
	return true
}

type Tenant struct {
	ID     string
	Name   string
	Region string
	Raw    json.RawMessage
}

type Cluster struct {
	ID         string
	Name       string
	DeployType string
	Provider   string
	Region     string
	TenantID   string
	Raw        json.RawMessage
}

type Operation struct {
	ID        string
	Name      string
	Kind      string
	Status    string
	ClusterID string
	CreatedAt *time.Time
	UpdatedAt *time.Time
	Raw       json.RawMessage
}

type ConfigChange struct {
	ID          string
	ClusterID   string
	ClusterType string
	Phase       string
	Status      string
	Reason      string
	CreatedAt   *time.Time
	UpdatedAt   *time.Time
	Raw         json.RawMessage
}

type httpClient struct {
	resty       *resty.Client
	frontendURL string
}

type devopsBaseResp struct {
	ErrCode    devopsErrCode `json:"errcode"`
	ErrMessage string        `json:"errmsg"`
	TraceID    string        `json:"trace_id"`
}

type devopsErrCode string

func (c *devopsErrCode) UnmarshalJSON(data []byte) error {
	if strings.TrimSpace(string(data)) == "null" {
		*c = ""
		return nil
	}

	var text string
	if err := json.Unmarshal(data, &text); err == nil {
		*c = devopsErrCode(text)
		return nil
	}

	var number json.Number
	if err := json.Unmarshal(data, &number); err == nil {
		*c = devopsErrCode(number.String())
		return nil
	}

	return fmt.Errorf("devops errcode must be a string or number: %s", string(data))
}

type clusterResp struct {
	devopsBaseResp
	ID         string `json:"id"`
	Name       string `json:"name"`
	DeployType string `json:"deploy_type"`
	Provider   string `json:"provider"`
	Region     string `json:"region"`
	TenantID   string `json:"tenant_id"`
}

type tenantResp struct {
	devopsBaseResp
	ID     string `json:"id"`
	Name   string `json:"name"`
	Region string `json:"region"`
}

type listResp[T any] struct {
	devopsBaseResp
	Total int64 `json:"total"`
	Items []T   `json:"items"`
}

type operationInstanceResp struct {
	ID        uint64            `json:"id"`
	Type      string            `json:"type"`
	Operation string            `json:"operation"`
	Reason    string            `json:"reason"`
	Applicant string            `json:"applicant"`
	Params    map[string]string `json:"params"`
	Outputs   map[string]string `json:"outputs"`
	Phase     string            `json:"phase"`
	CreatedAt int64             `json:"created_at"`
	ClosedAt  *int64            `json:"closed_at"`
}

type configChangeResp struct {
	ID            uint64             `json:"id"`
	TargetType    string             `json:"target_type"`
	TargetVersion string             `json:"target_version"`
	ChangeType    string             `json:"change_type"`
	Reason        string             `json:"reason"`
	Applicant     string             `json:"applicant"`
	ApplyPolicy   string             `json:"apply_policy"`
	Phase         string             `json:"phase"`
	CreatedAt     int64              `json:"created_at"`
	ClosedAt      *int64             `json:"closed_at"`
	AppliedAt     *int64             `json:"applied_at"`
	TargetCluster *targetClusterResp `json:"target_cluster"`
}

type targetClusterResp struct {
	ClusterType string `json:"cluster_type"`
	ClusterID   string `json:"cluster_id"`
}

const (
	listPageSize      = 100
	maxPaginatedPages = 1000
)

func NewClient(conf config.DevOpsConfig) Client {
	timeout := conf.Timeout
	if timeout <= 0 {
		timeout = 30 * time.Second
	}

	baseURL := strings.TrimRight(conf.BaseURL, "/")
	if baseURL == "" {
		baseURL = "https://prod.devops.pingcap.com"
	}
	frontendURL := strings.TrimRight(conf.FrontendURL, "/")
	if frontendURL == "" {
		frontendURL = "https://ops.tidbcloud.com"
	}

	cli := resty.New().
		SetBaseURL(baseURL).
		SetTimeout(timeout).
		SetHeader("Accept", "application/json")
	if conf.APIToken != "" {
		cli.SetHeader("X-API-TOKEN", conf.APIToken)
		cli.SetHeader("X-API-KEY", conf.APIToken)
	}

	return &httpClient{resty: cli, frontendURL: frontendURL}
}

func (c *httpClient) FrontendURL() string {
	return c.frontendURL
}

func (c *httpClient) GetTenant(ctx context.Context, tenantID string) (*Tenant, error) {
	var resp tenantResp
	if err := c.get(ctx, "/tenants/"+url.PathEscape(tenantID), nil, &resp); err != nil {
		return nil, err
	}
	return &Tenant{
		ID:     resp.ID,
		Name:   resp.Name,
		Region: resp.Region,
		Raw:    mustMarshalRaw(resp),
	}, nil
}

func (c *httpClient) GetCluster(ctx context.Context, clusterID string) (*Cluster, error) {
	var resp clusterResp
	if err := c.get(ctx, "/clusters/"+url.PathEscape(clusterID), nil, &resp); err != nil {
		return nil, err
	}
	return &Cluster{
		ID:         resp.ID,
		Name:       resp.Name,
		DeployType: resp.DeployType,
		Provider:   resp.Provider,
		Region:     resp.Region,
		TenantID:   resp.TenantID,
		Raw:        mustMarshalRaw(resp),
	}, nil
}

func (c *httpClient) ListOperations(ctx context.Context, clusterID string, window TimeWindow) ([]*Operation, error) {
	query := map[string]string{
		"type":      "cluster",
		"target_id": clusterID,
	}
	addOperationWindowQuery(query, window)

	items, err := listPaginated(ctx, c, "/operation/instances", query, window, operationCreatedAt)
	if err != nil {
		return nil, err
	}

	operations := make([]*Operation, 0, len(items))
	for _, item := range items {
		if item.ID == 0 {
			continue
		}
		operation := &Operation{
			ID:        strconv.FormatUint(item.ID, 10),
			Name:      item.Operation,
			Kind:      item.Type,
			Status:    item.Phase,
			ClusterID: clusterID,
			CreatedAt: unixTime(item.CreatedAt),
			UpdatedAt: unixTimePtr(item.ClosedAt),
			Raw:       mustMarshalRaw(item),
		}
		operations = append(operations, operation)
	}
	return operations, nil
}

func (c *httpClient) ListConfigChanges(ctx context.Context, clusterID, clusterType string, window TimeWindow) ([]*ConfigChange, error) {
	if clusterType == "" {
		return []*ConfigChange{}, nil
	}

	items, err := listPaginated(ctx, c, "/cloudconfig/cluster/changes", map[string]string{
		"cluster_id":   clusterID,
		"cluster_type": clusterType,
		"id_only":      "false",
	}, window, configChangeCreatedAt)
	if err != nil {
		return nil, err
	}

	changes := make([]*ConfigChange, 0, len(items))
	for _, item := range items {
		if item.ID == 0 {
			continue
		}
		change := &ConfigChange{
			ID:          strconv.FormatUint(item.ID, 10),
			ClusterID:   clusterID,
			ClusterType: clusterType,
			Phase:       item.Phase,
			Reason:      item.Reason,
			CreatedAt:   unixTime(item.CreatedAt),
			UpdatedAt:   firstUnixTimePtr(item.AppliedAt, item.ClosedAt),
			Raw:         mustMarshalRaw(item),
		}
		if item.TargetCluster != nil {
			change.ClusterID = item.TargetCluster.ClusterID
			change.ClusterType = item.TargetCluster.ClusterType
		}
		changes = append(changes, change)
	}
	return changes, nil
}

func listPaginated[T any](ctx context.Context, c *httpClient, path string, query map[string]string, window TimeWindow, createdAtOf func(T) int64) ([]T, error) {
	items := make([]T, 0)
	seenPages := map[string]struct{}{}
	for page := 1; page <= maxPaginatedPages; page++ {
		pageQuery := copyQuery(query)
		pageQuery["per_page"] = strconv.Itoa(listPageSize)
		pageQuery["page"] = strconv.Itoa(page)

		var resp listResp[T]
		if err := c.get(ctx, path, pageQuery, &resp); err != nil {
			return nil, err
		}

		if len(resp.Items) == 0 {
			return items, nil
		}
		signature := pageSignature(resp.Items)
		if _, ok := seenPages[signature]; ok {
			return items, nil
		}
		seenPages[signature] = struct{}{}

		pageTimes := make([]time.Time, 0, len(resp.Items))
		for _, item := range resp.Items {
			itemTime := unixTime(createdAtOf(item))
			if itemTime != nil {
				pageTimes = append(pageTimes, itemTime.UTC())
			}
			if window.Contains(itemTime) {
				items = append(items, item)
			}
		}
		if shouldStopPaginating(pageTimes, window) {
			return items, nil
		}
		if !hasNextPage(resp.Total, page, len(resp.Items), listPageSize) {
			return items, nil
		}
	}
	return nil, fmt.Errorf("devops list %s exceeded maximum page count %d", path, maxPaginatedPages)
}

func (c *httpClient) get(ctx context.Context, path string, query map[string]string, result any) error {
	req := c.resty.R().SetContext(ctx).SetResult(result)
	if len(query) > 0 {
		req.SetQueryParams(query)
	}

	resp, err := req.Get("/api/v1/devops" + path)
	if err != nil {
		return fmt.Errorf("devops get %s: %w", path, err)
	}
	if resp.IsError() {
		return fmt.Errorf("devops get %s failed: %s: %s", path, resp.Status(), string(resp.Body()))
	}
	return nil
}

func copyQuery(query map[string]string) map[string]string {
	copied := make(map[string]string, len(query)+2)
	for key, value := range query {
		copied[key] = value
	}
	return copied
}

func addOperationWindowQuery(query map[string]string, window TimeWindow) {
	if !window.Start.IsZero() {
		query["create_after"] = strconv.FormatInt(window.Start.UTC().Unix(), 10)
	}
}

func operationCreatedAt(item operationInstanceResp) int64 {
	return item.CreatedAt
}

func configChangeCreatedAt(item configChangeResp) int64 {
	return item.CreatedAt
}

func unixTime(ts int64) *time.Time {
	if ts <= 0 {
		return nil
	}
	t := time.Unix(ts, 0).UTC()
	return &t
}

func unixTimePtr(ts *int64) *time.Time {
	if ts == nil {
		return nil
	}
	return unixTime(*ts)
}

func firstUnixTimePtr(values ...*int64) *time.Time {
	for _, value := range values {
		if t := unixTimePtr(value); t != nil {
			return t
		}
	}
	return nil
}

func pageSignature[T any](items []T) string {
	data, err := json.Marshal(items)
	if err != nil {
		return fmt.Sprintf("%p:%d", items, len(items))
	}
	return string(data)
}

func hasNextPage(total int64, page int, itemCount int, perPage int) bool {
	if total > 0 {
		return int64(page*perPage) < total
	}
	return itemCount >= perPage
}

func shouldStopPaginating(pageTimes []time.Time, window TimeWindow) bool {
	if window.Start.IsZero() || len(pageTimes) == 0 {
		return false
	}
	if !timesNonIncreasing(pageTimes) {
		return false
	}
	oldest := pageTimes[len(pageTimes)-1]
	return !oldest.After(window.Start.UTC())
}

func timesNonIncreasing(values []time.Time) bool {
	if len(values) < 2 {
		return true
	}
	for idx := 1; idx < len(values); idx++ {
		if values[idx].After(values[idx-1]) {
			return false
		}
	}
	return true
}

func ClusterTypeFromDeployType(deployType string) string {
	switch deployType {
	case "DEPLOY_TYPE_DEDICATED":
		return "DEDICATED_TIDB"
	case "DEPLOY_TYPE_NEXTGEN":
		return "LOGICAL_CLUSTER"
	case "DEPLOY_TYPE_RESOURCE_POOL":
		return "SHARED_POOL"
	default:
		return ""
	}
}

func mustMarshalRaw(value any) json.RawMessage {
	data, err := json.Marshal(value)
	if err != nil {
		return json.RawMessage("{}")
	}
	return data
}
