package incident

import (
	"context"
	"errors"
	"fmt"
	"regexp"
	"strings"
	"time"

	gojira "github.com/andygrunwald/go-jira"
	"github.com/tidbcloud/devops/entity"
	jiraent "github.com/tidbcloud/devops/entity/jira"
	"github.com/tidbcloud/devops/models"
	"github.com/tidbcloud/devops/pkg/infra/clients/larkcli/notify"
	"github.com/tidbcloud/devops/pkg/service/jira/api"
	"github.com/trivago/tgo/tcontainer"

	"gorm.io/gorm"
)

const tcocpProjectKey = "TCOCP"

const (
	tcocpWorkTypeMitigationSOP = "Mitigation SOP" // #nosec G101 -- Jira work type display name, not a credential.
	tcocpWorkTypeNewSignal     = "New alert/metric/log"
	tcocpWorkTypeOperationSOP  = "Operation SOP" // #nosec G101 -- Jira work type display name, not a credential.
	tcocpWorkTypeHealthcheck   = "Healthcheck"
	tcocpWorkTypeConfigChange  = "ConfigChange"
	tcocpDefaultStatus         = "TO DO"
	tcocpDefaultPriority       = "P3"
	tcocpNoNeedStatus          = "NONEED"
	tcocpIssueLinkAssociated   = "Ticket-Oncall"
	tcocpPostmortemActionLabel = "postmortem-action-item"
	tcocpFieldETA              = "customfield_13303"
	tcocpFieldSyncToONCALL     = "customfield_10060"
	tcocpFieldFromTCOC         = "customfield_13567"
	tcocpOptionYesLower        = "yes"
	tcocpOptionYesTitle        = "Yes"
)

var jiraIssueKeyRegexp = regexp.MustCompile(`\b[A-Z][A-Z0-9]+-\d+\b`)

type linkedJiraIssues struct {
	TCOCP    []string
	TCOC     []string
	NonTCOCP []string
}

type tcocpIssue struct {
	Key     string
	Link    string
	Created bool
}

type tcocpActionFields struct {
	Persons        *string
	Deadline       *time.Time
	Priority       *string
	Status         *string
	Description    *string
	TCOCPCreatedAt *time.Time
}

func parseLinkedJiraIssues(raw *string) linkedJiraIssues {
	if raw == nil {
		return linkedJiraIssues{}
	}
	matches := jiraIssueKeyRegexp.FindAllString(strings.ToUpper(*raw), -1)
	seen := make(map[string]struct{}, len(matches))
	issues := linkedJiraIssues{}
	for _, match := range matches {
		if _, ok := seen[match]; ok {
			continue
		}
		seen[match] = struct{}{}
		if strings.HasPrefix(match, tcocpProjectKey+"-") {
			issues.TCOCP = append(issues.TCOCP, match)
			continue
		}
		if strings.HasPrefix(match, "TCOC-") {
			issues.TCOC = append(issues.TCOC, match)
			continue
		}
		issues.NonTCOCP = append(issues.NonTCOCP, match)
	}
	return issues
}

func firstTCOCPIssueKey(raw *string) string {
	issues := parseLinkedJiraIssues(raw)
	if len(issues.TCOCP) == 0 {
		return ""
	}
	return issues.TCOCP[0]
}

func isTCOCPLinked(raw *string) bool {
	return firstTCOCPIssueKey(raw) != ""
}

func jiraIssueLink(key string) string {
	key = strings.TrimSpace(strings.ToUpper(key))
	if key == "" {
		return ""
	}
	return fmt.Sprintf("https://tidb.atlassian.net/browse/%s", key)
}

func (s *incidentService) tcocpLinkForReminder(action *models.IncidentPostMortemToDoAction) string {
	if action == nil {
		return ""
	}
	return jiraIssueLink(firstTCOCPIssueKey(action.LinkedJiraIssue))
}

func (s *incidentService) ensureTCOCPIssueForToDoAction(
	ctx context.Context,
	action *entity.IncidentPostMortemToDoAction,
	incident *models.Incident,
	originalLinkedJiraIssue *string,
) (*tcocpIssue, error) {
	tcocpKey := firstTCOCPIssueKey(originalLinkedJiraIssue)
	if tcocpKey != "" {
		return &tcocpIssue{
			Key:     tcocpKey,
			Link:    jiraIssueLink(tcocpKey),
			Created: false,
		}, nil
	}
	if s.jiraCli == nil {
		return nil, errors.New("jira client is not configured")
	}
	if action == nil {
		return nil, errors.New("todo action is empty")
	}

	issue, err := s.buildTCOCPIssue(ctx, action, incident, originalLinkedJiraIssue)
	if err != nil {
		return nil, err
	}
	created, err := s.jiraCli.CreateIssue(ctx, issue)
	if err != nil {
		return nil, err
	}
	if created == nil || strings.TrimSpace(created.Key) == "" {
		return nil, errors.New("created TCOCP issue is empty")
	}
	if err := s.linkTCOCPToIncidentIssues(ctx, created.Key, incident); err != nil {
		return nil, err
	}
	return &tcocpIssue{
		Key:     created.Key,
		Link:    jiraIssueLink(created.Key),
		Created: true,
	}, nil
}

func (s *incidentService) ensureImportedToDoActionTCOCPLink(ctx context.Context, action *entity.IncidentPostMortemToDoAction, postmortemDocURL string, originalLinkedJiraIssue *string) error {
	if action == nil || action.Id == 0 {
		return nil
	}
	incident, err := s.loadIncidentModelForToDoAction(ctx, action, postmortemDocURL)
	if err != nil {
		return err
	}
	tcocp, err := s.ensureTCOCPIssueForToDoAction(ctx, action, incident, originalLinkedJiraIssue)
	if err != nil {
		return err
	}
	if tcocp == nil || strings.TrimSpace(tcocp.Key) == "" {
		return errors.New("TCOCP issue is empty")
	}
	tcocpKey := strings.TrimSpace(strings.ToUpper(tcocp.Key))
	action.LinkedJiraIssue = &tcocpKey
	wdb := s.dbProvider.WriteDB(ctx)
	if err := wdb.
		Model(&models.IncidentPostMortemToDoAction{}).
		Where("id = ?", action.Id).
		Update("linked_jira_issue", tcocpKey).Error; err != nil {
		return err
	}
	if err := s.syncImportedTCOCPFields(ctx, wdb, action); err != nil {
		return err
	}
	if tcocp.Created {
		s.notifyOwnersTCOCPCreatedBestEffort(ctx, action, incident, tcocp)
	}
	return nil
}

func (s *incidentService) syncImportedTCOCPFields(ctx context.Context, wdb *gorm.DB, action *entity.IncidentPostMortemToDoAction) error {
	if action == nil || action.Id == 0 || !isTCOCPLinked(action.LinkedJiraIssue) {
		return nil
	}
	mod := &models.IncidentPostMortemToDoAction{
		IncidentId:      action.IncidentId,
		LinkedJiraIssue: action.LinkedJiraIssue,
	}
	mod.ID = models.ID(action.Id)
	if err := s.syncOneToDoActionModelFromTCOCP(ctx, wdb, mod); err != nil {
		return err
	}
	if mod.Persons != nil {
		action.Persons = s.handleUserString(ctx, *mod.Persons, true)
	}
	action.Deadline = mod.Deadline
	action.Priority = mod.Priority
	action.Status = mod.Status
	action.Description = mod.Description
	action.TCOCPCreatedAt = mod.TCOCPCreatedAt
	return nil
}

func (s *incidentService) loadIncidentModelForToDoAction(ctx context.Context, action *entity.IncidentPostMortemToDoAction, postmortemDocURL string) (*models.Incident, error) {
	if action == nil || action.IncidentId == nil {
		return nil, nil
	}
	var incident models.Incident
	err := s.dbProvider.ReadDB(ctx).Where("id = ?", *action.IncidentId).First(&incident).Error
	if err != nil {
		return nil, fmt.Errorf("get incident %d for TCOCP creation failed: %w", *action.IncidentId, err)
	}
	if incident.FeishuLinks == nil && strings.TrimSpace(postmortemDocURL) != "" {
		incident.FeishuLinks = &postmortemDocURL
	}
	return &incident, nil
}

func (s *incidentService) SyncAllToDoActionStatusFromTCOCP(ctx context.Context) error {
	if s.jiraSvc == nil {
		return fmt.Errorf("jira service is not configured")
	}

	wdb := s.dbProvider.WriteDB(ctx)
	var actionModels []*models.IncidentPostMortemToDoAction
	result := wdb.
		Where("linked_jira_issue IS NOT NULL AND linked_jira_issue <> ''").
		Where("(status IS NULL OR LOWER(status) NOT IN ?)", []string{
			ToDoActionStatusDone,
			ToDoActionStatusNoNeed,
			ToDoActionStatusCanceled,
			"cancelled",
		}).
		Find(&actionModels)
	if result.Error != nil {
		return fmt.Errorf("get todo actions with linked jira issue failed: %w", result.Error)
	}
	s.syncToDoActionModelsFromTCOCPBestEffort(ctx, wdb, actionModels)
	return nil
}

func (s *incidentService) SyncToDoActionStatusFromTCOCP(ctx context.Context, incidentId uint64, postMortemId *uint64) error {
	if s.jiraSvc == nil {
		return fmt.Errorf("jira service is not configured")
	}

	wdb := s.dbProvider.WriteDB(ctx)
	var actionModels []*models.IncidentPostMortemToDoAction
	query := wdb.Where("incident_id = ?", incidentId)
	if realPostMortemId := s.resolvePostMortemID(ctx, incidentId, postMortemId); realPostMortemId != nil {
		query = query.Where("postmortem_id = ?", *realPostMortemId)
	}
	result := query.Find(&actionModels)
	if result.Error != nil {
		return fmt.Errorf("get todo actions failed: %w", result.Error)
	}
	return s.syncToDoActionModelsFromTCOCP(ctx, wdb, actionModels)
}

func (s *incidentService) syncToDoActionModelsFromTCOCP(ctx context.Context, wdb *gorm.DB, actionModels []*models.IncidentPostMortemToDoAction) error {
	var errs []error
	for _, mod := range actionModels {
		if mod == nil || !isTCOCPLinked(mod.LinkedJiraIssue) {
			continue
		}
		if err := s.syncOneToDoActionModelFromTCOCP(ctx, wdb, mod); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

func (s *incidentService) syncToDoActionModelsFromTCOCPBestEffort(ctx context.Context, wdb *gorm.DB, actionModels []*models.IncidentPostMortemToDoAction) {
	for _, mod := range actionModels {
		if mod == nil || !isTCOCPLinked(mod.LinkedJiraIssue) {
			continue
		}
		if err := s.syncOneToDoActionModelFromTCOCP(ctx, wdb, mod); err != nil {
			s.Logger(ctx).Error(err, "sync todo action from TCOCP failed, continue overdue reminder", "todo_action_id", mod.ID, "incident_id", mod.IncidentId)
		}
	}
}

func (s *incidentService) syncOneToDoActionModelFromTCOCP(ctx context.Context, wdb *gorm.DB, mod *models.IncidentPostMortemToDoAction) error {
	tcocpKey := firstTCOCPIssueKey(mod.LinkedJiraIssue)
	if tcocpKey == "" {
		return nil
	}

	issue, err := s.jiraSvc.GetIssueFromJira(ctx, tcocpKey, api.GetIssueFromJiraParam{})
	if err != nil {
		s.Logger(ctx).Error(err, "cannot get TCOCP issue", "tcocp_key", tcocpKey, "todo_action_id", mod.ID, "incident_id", mod.IncidentId)
		return fmt.Errorf("get TCOCP issue %s for todo action %d failed: %w", tcocpKey, mod.ID.Uint64(), err)
	}
	if issue == nil {
		return fmt.Errorf("TCOCP issue %s for todo action %d is empty", tcocpKey, mod.ID.Uint64())
	}

	fields := s.mapTCOCPIssueToActionFields(ctx, issue)
	updates := map[string]any{}
	if fields.Persons != nil {
		updates["persons"] = *fields.Persons
	}
	if fields.Deadline != nil {
		updates["deadline"] = *fields.Deadline
	}
	if fields.Priority != nil {
		updates["priority"] = *fields.Priority
	}
	if fields.Status != nil {
		updates["status"] = *fields.Status
	}
	if fields.Description != nil {
		updates["description"] = *fields.Description
	}
	if fields.TCOCPCreatedAt != nil {
		updates["tcocp_created_at"] = *fields.TCOCPCreatedAt
	}
	if len(updates) == 0 {
		return nil
	}

	err = wdb.Model(mod).Updates(updates).Error
	if err != nil {
		s.Logger(ctx).Error(err, "cannot sync TCOCP issue", "tcocp_key", tcocpKey, "todo_action_id", mod.ID, "incident_id", mod.IncidentId)
		return fmt.Errorf("sync TCOCP issue %s to todo action %d failed: %w", tcocpKey, mod.ID.Uint64(), err)
	}
	if fields.Persons != nil {
		mod.Persons = fields.Persons
	}
	if fields.Deadline != nil {
		mod.Deadline = fields.Deadline
	}
	if fields.Priority != nil {
		mod.Priority = fields.Priority
	}
	if fields.Status != nil {
		mod.Status = fields.Status
	}
	if fields.Description != nil {
		mod.Description = fields.Description
	}
	if fields.TCOCPCreatedAt != nil {
		mod.TCOCPCreatedAt = fields.TCOCPCreatedAt
	}
	return nil
}

func (s *incidentService) mapTCOCPIssueToActionFields(ctx context.Context, issue *jiraent.IssueInJira) tcocpActionFields {
	fields := tcocpActionFields{}
	if issue == nil {
		return fields
	}
	if issue.Assignee != nil && strings.TrimSpace(issue.Assignee.Email) != "" && s.userSvc != nil {
		users := s.emailsToUsers(ctx, []string{strings.TrimSpace(issue.Assignee.Email)})
		if len(users) > 0 {
			persons := strings.TrimSpace(s.convert2UserString(users))
			if persons != "" {
				fields.Persons = &persons
			}
		}
	}
	if issue.ETA != nil && *issue.ETA > 0 {
		deadline := time.Unix(*issue.ETA, 0)
		fields.Deadline = &deadline
	}
	if strings.TrimSpace(issue.Priority) != "" {
		priority := strings.TrimSpace(issue.Priority)
		fields.Priority = &priority
	}
	if status, ok := mapTCOCPStatusToToDoActionStatus(issue.Status); ok {
		fields.Status = &status
	} else if strings.TrimSpace(issue.Status) != "" {
		s.Logger(ctx).Info("skip unknown TCOCP status for todo action sync", "jira_status", issue.Status, "tcocp_key", issue.Key)
	}
	if strings.TrimSpace(issue.Summary) != "" {
		description := strings.TrimSpace(issue.Summary)
		fields.Description = &description
	}
	if issue.CreatedAt > 0 {
		tcocpCreatedAt := time.Unix(issue.CreatedAt, 0)
		fields.TCOCPCreatedAt = &tcocpCreatedAt
	}
	return fields
}

func mapTCOCPStatusToToDoActionStatus(jiraStatus string) (string, bool) {
	switch strings.TrimSpace(strings.ToUpper(jiraStatus)) {
	case "TO DO":
		return ToDoActionStatusOpen, true
	case "IN PROGRESS":
		return ToDoActionStatusInProgress, true
	case "DONE":
		return ToDoActionStatusDone, true
	case tcocpNoNeedStatus:
		return ToDoActionStatusNoNeed, true
	default:
		return "", false
	}
}

func (s *incidentService) buildTCOCPIssue(ctx context.Context, action *entity.IncidentPostMortemToDoAction, incident *models.Incident, originalLinkedJiraIssue *string) (*gojira.Issue, error) {
	workType := normalizeTCOCPWorkType(action.Type)
	if workType == "" {
		return nil, fmt.Errorf("invalid TCOCP work type: %v", action.Type)
	}

	incidentJiraIssues := parseIncidentJiraIssues(incident)
	unknowns := tcontainer.NewMarshalMap()
	if err := s.fillTCOCPCustomFields(ctx, unknowns, workType, action, len(incidentJiraIssues.TCOC) > 0); err != nil {
		return nil, err
	}

	fields := &gojira.IssueFields{
		Type: gojira.IssueType{
			Name: workType,
		},
		Project: gojira.Project{
			Key: tcocpProjectKey,
		},
		Summary:     actionTitle(action),
		Description: buildTCOCPDescription(action, incident, originalLinkedJiraIssue, s.incidentLinkForDescription(incident)),
		Labels:      buildTCOCPLabels(incident),
		Unknowns:    unknowns,
	}
	if action.Deadline != nil {
		fields.Duedate = gojira.Date(action.Deadline.In(time.UTC))
	}
	priority := tcocpDefaultPriority
	if action.Priority != nil && strings.TrimSpace(*action.Priority) != "" {
		priority = strings.TrimSpace(*action.Priority)
	}
	fields.Priority = &gojira.Priority{Name: priority}
	if assignee := s.findJiraAssigneeForToDoAction(ctx, action); assignee != nil {
		fields.Assignee = assignee
	}

	return &gojira.Issue{Fields: fields}, nil
}

func (s *incidentService) fillTCOCPCustomFields(ctx context.Context, unknowns tcontainer.MarshalMap, workType string, action *entity.IncidentPostMortemToDoAction, fromTCOC bool) error {
	setOptionCustomField(unknowns, tcocpFieldSyncToONCALL, tcocpOptionYesLower)
	if fromTCOC {
		setOptionCustomField(unknowns, tcocpFieldFromTCOC, tcocpOptionYesTitle)
	}
	if action.Deadline != nil {
		setDateCustomField(unknowns, tcocpFieldETA, *action.Deadline)
	}
	return nil
}

func normalizeTCOCPWorkType(workType *string) string {
	if workType == nil {
		return ""
	}
	normalized := strings.ToLower(strings.TrimSpace(*workType))
	switch normalized {
	case strings.ToLower(tcocpWorkTypeMitigationSOP):
		return tcocpWorkTypeMitigationSOP
	case strings.ToLower(tcocpWorkTypeNewSignal), "new alert", "new metric", "new log":
		return tcocpWorkTypeNewSignal
	case strings.ToLower(tcocpWorkTypeOperationSOP):
		return tcocpWorkTypeOperationSOP
	case strings.ToLower(tcocpWorkTypeHealthcheck):
		return tcocpWorkTypeHealthcheck
	case strings.ToLower(tcocpWorkTypeConfigChange), "config change":
		return tcocpWorkTypeConfigChange
	default:
		return ""
	}
}

func setOptionCustomField(unknowns tcontainer.MarshalMap, fieldID string, value string) {
	if fieldID == "" {
		return
	}
	unknowns[fieldID] = map[string]string{"value": value}
}

func setDateCustomField(unknowns tcontainer.MarshalMap, fieldID string, value time.Time) {
	if fieldID == "" {
		return
	}
	unknowns[fieldID] = value.In(time.UTC).Format("2006-01-02")
}

func (s *incidentService) findJiraAssigneeForToDoAction(ctx context.Context, action *entity.IncidentPostMortemToDoAction) *gojira.User {
	if s.jiraCli == nil || action == nil {
		return nil
	}
	for _, person := range action.Persons {
		if person == nil || strings.TrimSpace(person.Email) == "" {
			continue
		}
		email := strings.TrimSpace(person.Email)
		users, err := s.jiraCli.FindUsers(ctx, email, 5)
		if err != nil {
			s.Logger(ctx).Error(err, "find jira user by email failed", "email", email)
			continue
		}
		user := selectJiraUserForEmail(users, email)
		if user == nil || strings.TrimSpace(user.AccountID) == "" {
			s.Logger(ctx).Info("jira user not found by email", "email", email)
			continue
		}
		if !s.isJiraUserAssignable(ctx, tcocpProjectKey, user.AccountID, email) {
			continue
		}
		return &gojira.User{AccountID: user.AccountID}
	}
	return nil
}

func selectJiraUserForEmail(users []gojira.User, email string) *gojira.User {
	for i := range users {
		user := &users[i]
		if strings.TrimSpace(user.AccountID) == "" {
			continue
		}
		if strings.EqualFold(user.AccountType, "atlassian") && strings.EqualFold(user.EmailAddress, email) {
			return user
		}
	}
	for i := range users {
		user := &users[i]
		if strings.TrimSpace(user.AccountID) == "" {
			continue
		}
		if strings.EqualFold(user.AccountType, "atlassian") && strings.EqualFold(user.Name, email) {
			return user
		}
	}
	for i := range users {
		user := &users[i]
		if strings.TrimSpace(user.AccountID) == "" {
			continue
		}
		if strings.EqualFold(user.EmailAddress, email) || strings.EqualFold(user.Name, email) {
			return user
		}
	}
	for i := range users {
		user := &users[i]
		if strings.TrimSpace(user.AccountID) == "" {
			continue
		}
		if strings.EqualFold(user.AccountType, "atlassian") {
			return user
		}
	}
	return nil
}

func (s *incidentService) isJiraUserAssignable(ctx context.Context, projectKey, accountID, email string) bool {
	assignableUsers, err := s.jiraCli.FindAssignableUsers(ctx, projectKey, accountID, 5)
	if err != nil {
		s.Logger(ctx).Error(err, "find assignable jira user failed", "project", projectKey, "email", email, "account_id", accountID)
		return false
	}
	for _, user := range assignableUsers {
		if strings.EqualFold(user.AccountID, accountID) {
			return true
		}
	}
	s.Logger(ctx).Info("jira user is not assignable to project", "project", projectKey, "email", email, "account_id", accountID)
	return false
}

func (s *incidentService) incidentLinkForDescription(incident *models.Incident) string {
	if incident == nil || incident.ID.Uint64() == 0 {
		return ""
	}
	return s.withOpsPortalBaseURL(fmt.Sprintf("/incidents/%d", incident.ID.Uint64()))
}

func (s *incidentService) notifyOwnersTCOCPCreatedBestEffort(ctx context.Context, action *entity.IncidentPostMortemToDoAction, incident *models.Incident, tcocp *tcocpIssue) {
	if s.larkclient == nil || s.larkclient.Notify == nil || action == nil || tcocp == nil {
		return
	}
	for _, email := range ownerEmailsFromToDoAction(action) {
		param := s.buildTCOCPCreatedParam(action, incident, tcocp, email)
		if _, err := s.larkclient.Notify.IncidentToDoActionTCOCPCreated(ctx, email, param); err != nil {
			s.Logger(ctx).Error(err, "send TCOCP created incident todo action reminder failed", "todo_action_id", action.Id, "owner", email, "tcocp_key", tcocp.Key)
		}
	}
}

func (s *incidentService) buildTCOCPCreatedParam(action *entity.IncidentPostMortemToDoAction, incident *models.Incident, tcocp *tcocpIssue, ownerEmail string) notify.IncidentToDoActionTCOCPCreatedParam {
	tcocpKey := ""
	tcocpLink := ""
	if tcocp != nil {
		tcocpKey = strings.TrimSpace(strings.ToUpper(tcocp.Key))
		tcocpLink = strings.TrimSpace(tcocp.Link)
	}
	return notify.IncidentToDoActionTCOCPCreatedParam{
		IncidentName: incidentNameForReminder(incident),
		IncidentLink: s.incidentLinkForDescription(incident),
		ActionTitle:  actionTitle(action),
		DueTime:      entityToDoActionDueTimeForReminder(action),
		TCOCPLink:    tcocpLink,
		TCOCPKey:     tcocpKey,
		OwnerEmail:   ownerEmail,
	}
}

func ownerEmailsFromToDoAction(action *entity.IncidentPostMortemToDoAction) []string {
	if action == nil {
		return nil
	}
	emails := make([]string, 0, len(action.Persons))
	for _, person := range action.Persons {
		if person == nil {
			continue
		}
		emails = append(emails, person.Email)
	}
	return uniqueNonEmptyStrings(emails)
}

func entityToDoActionDueTimeForReminder(action *entity.IncidentPostMortemToDoAction) string {
	if action == nil || action.Deadline == nil {
		return ""
	}
	return action.Deadline.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02 15:04")
}

func buildTCOCPDescription(action *entity.IncidentPostMortemToDoAction, incident *models.Incident, originalLinkedJiraIssue *string, incidentLink string) string {
	parts := []string{
		"Created from incident postmortem action item.",
	}
	if incident != nil {
		parts = append(parts, fmt.Sprintf("Incident: %s", incidentNameForReminder(incident)))
		if strings.TrimSpace(incidentLink) != "" {
			parts = append(parts, fmt.Sprintf("Incident link: %s", strings.TrimSpace(incidentLink)))
		}
		if incident.FeishuLinks != nil && strings.TrimSpace(*incident.FeishuLinks) != "" {
			parts = append(parts, fmt.Sprintf("Postmortem document: %s", strings.TrimSpace(*incident.FeishuLinks)))
		}
	}
	if action != nil {
		if action.Description != nil && strings.TrimSpace(*action.Description) != "" {
			parts = append(parts, fmt.Sprintf("Action description:\n%s", strings.TrimSpace(*action.Description)))
		}
		if action.Deadline != nil {
			parts = append(parts, fmt.Sprintf("Deadline: %s", action.Deadline.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02 15:04")))
		}
		ownerEmails := make([]string, 0, len(action.Persons))
		for _, person := range action.Persons {
			if person != nil && strings.TrimSpace(person.Email) != "" {
				ownerEmails = append(ownerEmails, strings.TrimSpace(person.Email))
			}
		}
		if len(ownerEmails) > 0 {
			parts = append(parts, fmt.Sprintf("Owners: %s", strings.Join(uniqueNonEmptyStrings(ownerEmails), ", ")))
		}
	}
	if originalLinkedJiraIssue != nil {
		nonTCOCP := parseLinkedJiraIssues(originalLinkedJiraIssue).NonTCOCP
		if len(nonTCOCP) > 0 {
			parts = append(parts, fmt.Sprintf("Related non-TCOCP JSM issues: %s", strings.Join(nonTCOCP, ", ")))
		}
	}
	return strings.Join(parts, "\n\n")
}

func buildTCOCPLabels(incident *models.Incident) []string {
	labels := []string{tcocpPostmortemActionLabel}
	if incident != nil && incident.ID.Uint64() != 0 {
		labels = append(labels, fmt.Sprintf("incident-%d", incident.ID.Uint64()))
	}
	return labels
}

func actionTitle(action *entity.IncidentPostMortemToDoAction) string {
	if action == nil || action.Title == nil || strings.TrimSpace(*action.Title) == "" {
		return "(untitled postmortem action)"
	}
	return strings.TrimSpace(*action.Title)
}

func (s *incidentService) linkTCOCPToIncidentIssues(ctx context.Context, tcocpKey string, incident *models.Incident) error {
	if s.jiraCli == nil {
		return nil
	}
	incidentIssueKeys := parseIncidentJiraIssues(incident)
	var errs []error
	for _, key := range incidentIssueKeys.TCOC {
		if key == "" || strings.EqualFold(key, tcocpKey) {
			continue
		}
		if err := s.jiraCli.CreateIssueLink(ctx, tcocpIssueLinkAssociated, tcocpKey, key); err != nil {
			errs = append(errs, fmt.Errorf("link %s with %s failed: %w", tcocpKey, key, err))
		}
	}
	return errors.Join(errs...)
}

func parseIncidentJiraIssues(incident *models.Incident) linkedJiraIssues {
	if incident == nil {
		return linkedJiraIssues{}
	}
	return parseLinkedJiraIssues(incident.JiraIssues)
}
