//go:generate ${GOBIN}/mockgen -destination issue_loader_mock.go -package=issueloader ${GO_MODULE}/pkg/service/jira/issueloader IssueLoader
package issueloader

import (
	"context"
	"fmt"
	"strings"
	"time"

	jiraent "github.com/tidbcloud/devops/entity/jira"
	"github.com/tidbcloud/devops/pkg/infra/clients/jiracli"
	"github.com/tidbcloud/devops/pkg/infra/log"
	"github.com/tidbcloud/devops/pkg/infra/utils/container"
	"github.com/tidbcloud/devops/pkg/infra/utils/pointer"

	gojira "github.com/andygrunwald/go-jira"
)

const (
	CLUSTER_ID_FIELD     = "customfield_10149"
	TENANT_ID_FIELD      = "customfield_10157"
	JSM_TENANT_ID_FIELD  = "customfield_10110"
	JSM_CLUSTER_ID_FIELD = "customfield_10111"
	OP_ACCOUNT_ID_FIELD  = "customfield_10097"
	TCOCP_ETA_FIELD      = "customfield_13303"
	COMMENT_FIELD        = "comment"

	JiraISOTimeLayout = "2006-01-02T15:04:05.999-0700"
)

type IssueFieldOptions struct {
	WithComments bool
}

func (o IssueFieldOptions) BuildFields() []string {
	basic := []string{
		"summary", "project", "created", "updated", "description",
		"priority", "status", "components", "assignee", "reporter", "issuelinks", "resolutiondate",
		CLUSTER_ID_FIELD, TENANT_ID_FIELD,
		JSM_TENANT_ID_FIELD, JSM_CLUSTER_ID_FIELD,
		OP_ACCOUNT_ID_FIELD,
		TCOCP_ETA_FIELD,
	}

	if o.WithComments {
		basic = append(basic, COMMENT_FIELD)
	}

	return basic
}

type LoadByJQLOptions struct {
	Fields   IssueFieldOptions
	MaxCount int  // max count of issues to load, limit to 500
	FailFast bool // whether to stop when encountering an error
	PerPage  int  // items per page when fetching from Jira, default to 50
}

func (o *LoadByJQLOptions) WithDefaults() {
	if o.MaxCount <= 0 || o.MaxCount > 200 {
		o.MaxCount = 500
	}
	if o.PerPage <= 0 || o.PerPage > 100 {
		o.PerPage = 50
	}
}

type LoadByJQLResult struct {
	Total         int
	SuccessIssues []string
	FailureIssues []string
}

type LoadByJQLPagedOptions struct {
	Fields    IssueFieldOptions
	PerPage   int
	PageToken string
}

func (o *LoadByJQLPagedOptions) WithDefaults() {
	if o.PerPage <= 0 || o.PerPage > 100 {
		o.PerPage = 50
	}
}

type LoadByJQLPagedResult struct {
	IsLast        bool
	NextPageToken string
}

type IssueLoader interface {
	// LoadIssueInRepoByJQL loads issues from Jira by JQL.
	LoadIssueInRepoByJQL(ctx context.Context, jql string, option *LoadByJQLOptions) ([]*jiraent.IssueSnapshot, *LoadByJQLResult, error)
	// LoadIssueInRepoByKey loads an issue from Jira by key.
	LoadIssueInRepoByKey(ctx context.Context, key string) (*jiraent.IssueSnapshot, error)

	// LoadIssueInJiraByJQL loads issues from Jira by JQL.
	LoadIssueInJiraByJQL(ctx context.Context, jql string, option *LoadByJQLOptions) ([]*jiraent.IssueInJira, *LoadByJQLResult, error)
	// LoadIssueInJiraByKey loads an issue from Jira by key.
	LoadIssueInJiraByKey(ctx context.Context, key string, option IssueFieldOptions) (*jiraent.IssueInJira, error)
	// LoadIssueInJiraByJQLPaged loads issues from Jira by JQL with pagination support.
	LoadIssueInJiraByJQLPaged(ctx context.Context, jql string, option *LoadByJQLPagedOptions) ([]*jiraent.IssueInJira, *LoadByJQLPagedResult, error)
}

func NewIssueLoader(jiraClient jiracli.JiraClient) IssueLoader {
	return &issueLoader{
		jiraClient: jiraClient,
		jsmObjCache: &jsmObjectCache{
			jiraClient: jiraClient,
			m:          container.NewMap[string, *jiracli.AssetObject](),
		},
	}
}

type jsmObjectCache struct {
	jiraClient jiracli.JiraClient
	m          *container.Map[string, *jiracli.AssetObject]
}

func (c *jsmObjectCache) GetOrStore(ctx context.Context, workspaceID, objectID string) (*jiracli.AssetObject, error) {
	key := c.Key(workspaceID, objectID)

	obj, exist := c.m.Load(key)
	if exist {
		return obj, nil
	}

	obj, err := c.jiraClient.GetAssetObject(ctx, workspaceID, objectID)
	if err != nil {
		return nil, err
	}

	c.m.Store(key, obj)
	return obj, nil
}

func (c *jsmObjectCache) Key(workspaceID, objectID string) string {
	return fmt.Sprintf("%s:%s", workspaceID, objectID)
}

type issueLoader struct {
	jiraClient  jiracli.JiraClient
	jsmObjCache *jsmObjectCache
}

func (l *issueLoader) LoadIssueInRepoByJQL(ctx context.Context, jql string, option *LoadByJQLOptions) ([]*jiraent.IssueSnapshot, *LoadByJQLResult, error) {
	option.WithDefaults()

	result := &LoadByJQLResult{
		Total:         0,
		SuccessIssues: make([]string, 0),
		FailureIssues: make([]string, 0),
	}
	issues := make([]*jiraent.IssueSnapshot, 0, option.MaxCount)
	curPageToken := ""

	for result.Total < option.MaxCount {
		apiResult, err := l.jiraClient.SearchIssues(ctx, jql, &jiracli.SearchOptions{
			NextPageToken: curPageToken,
			Fields:        option.Fields.BuildFields(),
			MaxResults:    option.PerPage,
		})
		if err != nil {
			return nil, nil, err
		}

		result.Total += len(apiResult.Issues)
		for _, rawIssue := range apiResult.Issues {
			issue, err := l.buildIssueInRepoFromRaw(ctx, rawIssue)
			if err != nil {
				if option.FailFast {
					return nil, nil, fmt.Errorf("build issue customer info failed: %w", err)
				}

				result.FailureIssues = append(result.FailureIssues, rawIssue.Key)
				log.FromContext(ctx).Error(err, "ignore an issue cause building failed", "issue", rawIssue.Key)
				continue
			}

			issues = append(issues, issue)
			result.SuccessIssues = append(result.SuccessIssues, rawIssue.Key)
		}

		// next page
		if apiResult.IsLast || apiResult.NextPageToken == "" {
			break
		}
		curPageToken = apiResult.NextPageToken
	}

	return issues, result, nil
}

func (l *issueLoader) LoadIssueInRepoByKey(ctx context.Context, key string) (*jiraent.IssueSnapshot, error) {
	rawIssue, err := l.jiraClient.GetIssue(ctx, key, &gojira.GetQueryOptions{
		Fields: strings.Join(IssueFieldOptions{}.BuildFields(), ","),
	})
	if err != nil {
		return nil, fmt.Errorf("fetch issue '%s' from jira failed: %w", key, err)
	}

	issue, err := l.buildIssueInRepoFromRaw(ctx, *rawIssue)
	if err != nil {
		return nil, fmt.Errorf("build issue customer info failed: %w", err)
	}

	return issue, nil
}

func (l *issueLoader) LoadIssueInJiraByJQL(ctx context.Context, jql string, option *LoadByJQLOptions) ([]*jiraent.IssueInJira, *LoadByJQLResult, error) {
	option.WithDefaults()

	result := &LoadByJQLResult{
		Total:         0,
		SuccessIssues: make([]string, 0),
		FailureIssues: make([]string, 0),
	}
	issues := make([]*jiraent.IssueInJira, 0, option.MaxCount)
	curPageToken := ""

	for result.Total < option.MaxCount {
		apiResult, err := l.jiraClient.SearchIssues(ctx, jql, &jiracli.SearchOptions{
			NextPageToken: curPageToken,
			Fields:        option.Fields.BuildFields(),
			MaxResults:    option.PerPage,
		})
		if err != nil {
			return nil, nil, err
		}

		result.Total += len(apiResult.Issues)
		for _, rawIssue := range apiResult.Issues {
			issue, err := l.buildIssueInJiraFromRaw(ctx, rawIssue)
			if err != nil {
				if option.FailFast {
					return nil, nil, fmt.Errorf("build issue customer info failed: %w", err)
				}

				result.FailureIssues = append(result.FailureIssues, rawIssue.Key)
				log.FromContext(ctx).Error(err, "ignore an issue cause building failed", "issue", rawIssue.Key)
				continue
			}

			issues = append(issues, issue)
			result.SuccessIssues = append(result.SuccessIssues, rawIssue.Key)
		}

		// next page
		if apiResult.IsLast || apiResult.NextPageToken == "" {
			break
		}
		curPageToken = apiResult.NextPageToken
	}

	return issues, result, nil
}

func (l *issueLoader) LoadIssueInJiraByKey(ctx context.Context, key string, option IssueFieldOptions) (*jiraent.IssueInJira, error) {
	rawIssue, err := l.jiraClient.GetIssue(ctx, key, &gojira.GetQueryOptions{
		Fields: strings.Join(option.BuildFields(), ","),
	})
	if err != nil {
		return nil, fmt.Errorf("fetch issue '%s' from jira failed: %w", key, err)
	}

	issue, err := l.buildIssueInJiraFromRaw(ctx, *rawIssue)
	if err != nil {
		return nil, err
	}

	return issue, nil
}

func (l *issueLoader) LoadIssueInJiraByJQLPaged(ctx context.Context, jql string, option *LoadByJQLPagedOptions) ([]*jiraent.IssueInJira, *LoadByJQLPagedResult, error) {
	apiResult, err := l.jiraClient.SearchIssues(ctx, jql, &jiracli.SearchOptions{
		NextPageToken: option.PageToken,
		Fields:        option.Fields.BuildFields(),
		MaxResults:    option.PerPage,
	})
	if err != nil {
		return nil, nil, err
	}

	issues := make([]*jiraent.IssueInJira, 0, len(apiResult.Issues))
	for _, rawIssue := range apiResult.Issues {
		issue, err := l.buildIssueInJiraFromRaw(ctx, rawIssue)
		if err != nil {
			return nil, nil, fmt.Errorf("build issue customer info failed: %w", err)
		}

		issues = append(issues, issue)
	}

	result := &LoadByJQLPagedResult{
		IsLast:        apiResult.IsLast,
		NextPageToken: apiResult.NextPageToken,
	}

	return issues, result, nil
}

func (l *issueLoader) buildIssueInRepoFromRaw(ctx context.Context, rawIssue gojira.Issue) (*jiraent.IssueSnapshot, error) {
	issueInJira, err := l.buildIssueInJiraFromRaw(ctx, rawIssue)
	if err != nil {
		return nil, err
	}

	issue := &jiraent.IssueSnapshot{
		Key:          issueInJira.Key,
		ID:           issueInJira.ID,
		Summary:      issueInJira.Summary,
		Status:       issueInJira.Status,
		Priority:     issueInJira.Priority,
		CreatedAt:    issueInJira.CreatedAt,
		ResolvedAt:   issueInJira.ResolvedAt,
		ProjectKey:   issueInJira.Project.Key,
		CustomerInfo: issueInJira.CustomerInfo,
		Components:   issueInJira.Components,
	}

	if issueInJira.Assignee != nil {
		issue.AssigneeEmail = pointer.Pointer(issueInJira.Assignee.Email)
	}

	if issueInJira.Reporter != nil {
		issue.ReporterEmail = pointer.Pointer(issueInJira.Reporter.Email)
	}

	return issue, nil
}

func (l *issueLoader) buildIssueInJiraFromRaw(ctx context.Context, rawIssue gojira.Issue) (*jiraent.IssueInJira, error) {
	issue := &jiraent.IssueInJira{
		ID:          rawIssue.ID,
		Key:         rawIssue.Key,
		Summary:     rawIssue.Fields.Summary,
		CreatedAt:   time.Time(rawIssue.Fields.Created).Unix(),
		UpdatedAt:   time.Time(rawIssue.Fields.Updated).Unix(),
		Description: rawIssue.Fields.Description,
		Project: jiraent.Project{
			Name: rawIssue.Fields.Project.Name,
			Key:  rawIssue.Fields.Project.Key,
		},
	}

	if !time.Time(rawIssue.Fields.Resolutiondate).IsZero() {
		issue.ResolvedAt = pointer.Pointer(time.Time(rawIssue.Fields.Resolutiondate).Unix())
	}
	if eta := parseJiraDateCustomField(ctx, rawIssue.Fields.Unknowns, TCOCP_ETA_FIELD); eta != nil {
		issue.ETA = pointer.Pointer(eta.Unix())
	}

	if rawIssue.Fields.Status != nil {
		issue.Status = rawIssue.Fields.Status.Name
	}

	if rawIssue.Fields.Priority != nil {
		issue.Priority = rawIssue.Fields.Priority.Name
	}

	if rawIssue.Fields.Assignee != nil {
		issue.Assignee = &jiraent.User{
			Email:       rawIssue.Fields.Assignee.EmailAddress,
			DisplayName: rawIssue.Fields.Assignee.DisplayName,
		}
	}

	if rawIssue.Fields.Reporter != nil {
		issue.Reporter = &jiraent.User{
			Email:       rawIssue.Fields.Reporter.EmailAddress,
			DisplayName: rawIssue.Fields.Reporter.DisplayName,
		}
	}

	for _, component := range rawIssue.Fields.Components {
		issue.Components = append(issue.Components, component.Name)
	}

	for _, rawLink := range rawIssue.Fields.IssueLinks {
		il := jiraent.IssueLink{
			TypeName: rawLink.Type.Name,
		}
		if rawLink.InwardIssue != nil {
			il.InwardIssueKey = pointer.Pointer(rawLink.InwardIssue.Key)
		}
		if rawLink.OutwardIssue != nil {
			il.OutwardIssueKey = pointer.Pointer(rawLink.OutwardIssue.Key)
		}
		issue.IssueLinks = append(issue.IssueLinks, il)
	}

	if rawIssue.Fields.Comments != nil {
		for _, rawComment := range rawIssue.Fields.Comments.Comments {
			comment := jiraent.Comment{
				ID:        rawComment.ID,
				CreatedAt: 0,
				UpdatedAt: 0,
				Author: &jiraent.User{
					Email:       rawComment.Author.EmailAddress,
					DisplayName: rawComment.Author.DisplayName,
				},
				Content: rawComment.Body,
			}

			if t, err := parseISOTime(rawComment.Created); err == nil {
				comment.CreatedAt = t.Unix()
			}
			if t, err := parseISOTime(rawComment.Updated); err == nil {
				comment.UpdatedAt = t.Unix()
			}

			issue.Comments = append(issue.Comments, comment)
		}
	}

	issueCustomerInfo, err := l.buildIssueCustomerInfoFromRaw(ctx, rawIssue)
	if err != nil {
		return nil, fmt.Errorf("parse issue customer info failed: %w", err)
	}
	issue.CustomerInfo = issueCustomerInfo

	return issue, nil
}

func parseJiraDateCustomField(ctx context.Context, unknowns map[string]any, field string) *time.Time {
	if unknowns == nil {
		return nil
	}
	raw, ok := unknowns[field]
	if !ok || raw == nil {
		return nil
	}
	rawString, ok := raw.(string)
	if !ok {
		log.FromContext(ctx).Info("skip non-string jira date custom field", "field", field, "value_type", fmt.Sprintf("%T", raw))
		return nil
	}
	rawString = strings.TrimSpace(rawString)
	if rawString == "" {
		return nil
	}
	parsed, err := time.Parse("2006-01-02", rawString)
	if err != nil {
		log.FromContext(ctx).Error(err, "skip invalid jira date custom field", "field", field, "value", rawString)
		return nil
	}
	return &parsed
}

func (l *issueLoader) buildIssueCustomerInfoFromRaw(ctx context.Context, rawIssue gojira.Issue) (*jiraent.IssueCustomerInfo, error) {
	info := &jiraent.IssueCustomerInfo{
		IssueKey: rawIssue.Key,
	}

	switch rawIssue.Fields.Project.Key {
	case "ST", "TCID":
		if v, exist := rawIssue.Fields.Unknowns[JSM_CLUSTER_ID_FIELD]; exist && v != nil {
			arrayList, err := rawIssue.Fields.Unknowns.Array(JSM_CLUSTER_ID_FIELD)
			if err != nil {
				return nil, fmt.Errorf("unexpected type for field '%s': %T", JSM_CLUSTER_ID_FIELD, rawIssue.Fields.Unknowns[JSM_CLUSTER_ID_FIELD])
			}
			if len(arrayList) > 0 {
				asset, err := l.readJSMField(ctx, rawIssue, JSM_CLUSTER_ID_FIELD)
				if err != nil {
					return nil, err
				}
				info.ClusterID = pointer.Pointer(asset.Label)
			}
		}
		if v, exist := rawIssue.Fields.Unknowns[JSM_TENANT_ID_FIELD]; exist && v != nil {
			arrayList, err := rawIssue.Fields.Unknowns.Array(JSM_TENANT_ID_FIELD)
			if err != nil {
				return nil, fmt.Errorf("unexpected type for field '%s': %T", JSM_TENANT_ID_FIELD, rawIssue.Fields.Unknowns[JSM_TENANT_ID_FIELD])
			}
			if len(arrayList) > 0 {
				asset, err := l.readJSMField(ctx, rawIssue, JSM_TENANT_ID_FIELD)
				if err != nil {
					return nil, err
				}
				info.TenantID = pointer.Pointer(asset.Label)
			}
		}
		info.Environment = jiraent.IssueCustomerEnvironmentCloud

	case "TCOC":
		if v, exist := rawIssue.Fields.Unknowns[CLUSTER_ID_FIELD]; exist && v != nil {
			clusterID, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("unexpected type for field '%s': %T", CLUSTER_ID_FIELD, v)
			}
			if clusterID != "" {
				info.ClusterID = pointer.Pointer(clusterID)
			}
		}
		if v, exist := rawIssue.Fields.Unknowns[TENANT_ID_FIELD]; exist && v != nil {
			tenantID, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("unexpected type for field '%s': %T", TENANT_ID_FIELD, v)
			}
			if tenantID != "" {
				info.TenantID = pointer.Pointer(tenantID)
			}
		}
		info.Environment = jiraent.IssueCustomerEnvironmentCloud

	case "JPID", "APID":
		if v, exist := rawIssue.Fields.Unknowns[OP_ACCOUNT_ID_FIELD]; exist && v != nil {
			tenantID, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("unexpected type for field '%s': %T", OP_ACCOUNT_ID_FIELD, v)
			}
			if tenantID != "" {
				info.TenantID = pointer.Pointer(tenantID)
			}
		}
		info.Environment = jiraent.IssueCustomerEnvironmentOnPremise

	case "O11Y":
		if v, exist := rawIssue.Fields.Unknowns[CLUSTER_ID_FIELD]; exist && v != nil {
			clusterID, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("unexpected type for field '%s': %T", CLUSTER_ID_FIELD, v)
			}
			if clusterID != "" {
				info.ClusterID = pointer.Pointer(clusterID)
			}
		}
		if v, exist := rawIssue.Fields.Unknowns[TENANT_ID_FIELD]; exist && v != nil {
			tenantID, ok := v.(string)
			if !ok {
				return nil, fmt.Errorf("unexpected type for field '%s': %T", TENANT_ID_FIELD, v)
			}
			if tenantID != "" {
				info.TenantID = pointer.Pointer(tenantID)
			}
		}
		info.Environment = jiraent.IssueCustomerEnvironmentCloud

	default:
		return nil, nil
	}

	return info, nil
}

func (l *issueLoader) readJSMField(ctx context.Context, rawIssue gojira.Issue, field string) (*jiracli.AssetObject, error) {
	workspacePath := "[0]workspaceId"
	objectIDPath := "[0]objectId"

	workspaceID, err := rawIssue.Fields.Unknowns.String(field + workspacePath)
	if err != nil {
		return nil, fmt.Errorf("unexpected type for field: %s", err)
	}
	objectID, err := rawIssue.Fields.Unknowns.String(field + objectIDPath)
	if err != nil {
		return nil, fmt.Errorf("unexpected type for field: %T", err)
	}

	asset, err := l.jsmObjCache.GetOrStore(ctx, workspaceID, objectID)
	if err != nil {
		return nil, fmt.Errorf("get jsm asset object failed: %w", err)
	}

	return asset, nil
}

func parseISOTime(timeStr string) (time.Time, error) {
	ti, err := time.Parse(JiraISOTimeLayout, string(timeStr))
	if err != nil {
		return time.Time{}, err
	}
	return ti, nil
}
