//go:generate ${GOBIN}/mockgen -destination jiracli_mock.go -package=jiracli ${GO_MODULE}/pkg/infra/clients/jiracli JiraClient
package jiracli

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

	"github.com/tidbcloud/devops/pkg/infra/config"

	"github.com/andygrunwald/go-jira"
	"github.com/go-logr/logr"
	"github.com/go-resty/resty/v2"
	"go.uber.org/dig"
)

type ClientParam struct {
	dig.In

	Conf   *config.RootConfig
	Logger logr.Logger
}

type JiraClient interface {
	Raw() *jira.Client

	ListVersions(ctx context.Context, project string) ([]jira.Version, error)

	SearchIssues(ctx context.Context, jql string, options *SearchOptions) (*SearchResults, error)
	GetIssue(ctx context.Context, name string, options *jira.GetQueryOptions) (*jira.Issue, error)
	FindUsers(ctx context.Context, query string, maxResults int) ([]jira.User, error)
	FindAssignableUsers(ctx context.Context, projectKey, query string, maxResults int) ([]jira.User, error)
	UpdateIssueStatus(ctx context.Context, name string, status string) error
	CreateIssue(ctx context.Context, issue *jira.Issue) (*jira.Issue, error)
	CreateIssueLink(ctx context.Context, linkType, inwardIssueKey, outwardIssueKey string) error

	BindLarkGroupToIssue(ctx context.Context, issueID, groupID string) error
	GetBindingLarkGroupOfIssue(ctx context.Context, issueID string) (string, error)

	GetAssetObject(ctx context.Context, workspaceID, objectID string) (*AssetObject, error)
}

type jiraClient struct {
	conf      config.JiraConfig
	logger    logr.Logger
	cli       *jira.Client
	jiraResty *resty.Client
	jsmResty  *resty.Client
}

func NewJiraClient(param ClientParam) (JiraClient, error) {
	tp := jira.BasicAuthTransport{
		Username:  param.Conf.Clients.Jira.JiraUsername,
		Password:  param.Conf.Clients.Jira.JiraPassword,
		Transport: http.DefaultTransport,
	}
	client, err := jira.NewClient(tp.Client(), param.Conf.Clients.Jira.JiraURL)
	if err != nil {
		return nil, fmt.Errorf("create jira client failed: %s", err)
	}

	jiraResty := resty.New().
		SetBaseURL(param.Conf.Clients.Jira.JiraURL).
		SetBasicAuth(param.Conf.Clients.Jira.JiraUsername, param.Conf.Clients.Jira.JiraPassword).
		OnAfterResponse(func(c *resty.Client, r *resty.Response) error {
			if !r.IsSuccess() {
				return fmt.Errorf("status: %s, body: %s", r.Status(), string(r.Body()))
			}
			return nil
		})

	jsmResty := resty.New().
		SetBaseURL("https://api.atlassian.com").
		SetBasicAuth(param.Conf.Clients.Jira.JiraUsername, param.Conf.Clients.Jira.JiraPassword).
		SetTimeout(5 * time.Second).
		OnAfterResponse(func(c *resty.Client, r *resty.Response) error {
			if !r.IsSuccess() {
				return fmt.Errorf("status: %s, body: %s", r.Status(), string(r.Body()))
			}
			return nil
		})

	return &jiraClient{
		conf:      param.Conf.Clients.Jira,
		logger:    param.Logger.WithName("JiraClient"),
		cli:       client,
		jiraResty: jiraResty,
		jsmResty:  jsmResty,
	}, nil
}

func (j *jiraClient) Raw() *jira.Client {
	return j.cli
}

func (c *jiraClient) ListVersions(ctx context.Context, projectName string) ([]jira.Version, error) {
	project, _, err := c.cli.Project.GetWithContext(ctx, projectName)
	if err != nil {
		return nil, fmt.Errorf("get project info failed: %s", err)
	}

	return project.Versions, nil
}

func (c *jiraClient) GetIssue(ctx context.Context, name string, options *jira.GetQueryOptions) (*jira.Issue, error) {
	issue, _, err := c.cli.Issue.GetWithContext(ctx, name, options)
	if err != nil {
		return nil, fmt.Errorf("get ticket %s failed: %s", name, err)
	}
	return issue, nil
}

func (c *jiraClient) FindUsers(ctx context.Context, query string, maxResults int) ([]jira.User, error) {
	if maxResults <= 0 {
		maxResults = 10
	}
	var users []jira.User
	_, err := c.jiraResty.R().
		SetContext(ctx).
		SetQueryParams(map[string]string{
			"query":      query,
			"maxResults": strconv.Itoa(maxResults),
		}).
		SetResult(&users).
		Get("/rest/api/3/user/search")
	if err != nil {
		return nil, fmt.Errorf("find jira users failed: %s", err)
	}
	return users, nil
}

func (c *jiraClient) FindAssignableUsers(ctx context.Context, projectKey, query string, maxResults int) ([]jira.User, error) {
	if maxResults <= 0 {
		maxResults = 10
	}
	var users []jira.User
	_, err := c.jiraResty.R().
		SetContext(ctx).
		SetQueryParams(map[string]string{
			"project":    projectKey,
			"query":      query,
			"maxResults": strconv.Itoa(maxResults),
		}).
		SetResult(&users).
		Get("/rest/api/3/user/assignable/search")
	if err != nil {
		return nil, fmt.Errorf("find assignable jira users failed: %s", err)
	}
	return users, nil
}

func (c *jiraClient) UpdateIssueStatus(ctx context.Context, name string, status string) error {
	transitions, _, err := c.cli.Issue.GetTransitionsWithContext(ctx, name)
	if err != nil {
		return fmt.Errorf("get issue %s transitions failed: %s", name, err)
	}

	tid := ""
	for _, tran := range transitions {
		if tran.To.Name == status {
			tid = tran.ID
			break
		}
	}
	if tid == "" {
		return fmt.Errorf("can't transition issue %s status to %s", name, status)
	}

	_, err = c.cli.Issue.DoTransitionWithContext(ctx, name, tid)
	if err != nil {
		return fmt.Errorf("transition issue %s status to %s failed: %s", name, status, err)
	}

	return nil
}

func (c *jiraClient) CreateIssue(ctx context.Context, issue *jira.Issue) (*jira.Issue, error) {
	createdIssue, err := c.createIssueWithJiraV3(ctx, issue)
	if err != nil && jiraCreateIssueErrorAllowsAssigneeRetry(err) && issue != nil && issue.Fields != nil && issue.Fields.Assignee != nil {
		c.logger.Error(err, "create jira issue failed with assignee, retry without assignee", "request_summary", jiraIssueCreateLogSummary(issue))
		retryIssue := cloneJiraIssueForCreate(issue)
		retryIssue.Fields.Assignee = nil
		createdIssue, err = c.createIssueWithJiraV3(ctx, retryIssue)
	}
	if err != nil {
		c.logger.Error(err, "create jira issue failed", "request_summary", jiraIssueCreateLogSummary(issue))
		return nil, fmt.Errorf("create issue failed: %s", err)
	}
	return createdIssue, nil
}

func (c *jiraClient) createIssueWithJiraV3(ctx context.Context, issue *jira.Issue) (*jira.Issue, error) {
	body, err := jiraIssueCreateRequestBody(issue)
	if err != nil {
		return nil, err
	}
	var created jira.Issue
	_, err = c.jiraResty.R().
		SetContext(ctx).
		SetBody(body).
		SetResult(&created).
		Post("/rest/api/3/issue")
	if err != nil {
		return nil, err
	}
	return &created, nil
}

func jiraIssueCreateRequestBody(issue *jira.Issue) (map[string]any, error) {
	if issue == nil {
		return nil, fmt.Errorf("jira issue is empty")
	}
	raw, err := json.Marshal(issue)
	if err != nil {
		return nil, fmt.Errorf("marshal jira issue failed: %w", err)
	}
	var body map[string]any
	if err := json.Unmarshal(raw, &body); err != nil {
		return nil, fmt.Errorf("unmarshal jira issue body failed: %w", err)
	}
	fields, ok := body["fields"].(map[string]any)
	if !ok {
		return nil, fmt.Errorf("jira issue fields are empty")
	}
	if description, ok := fields["description"].(string); ok {
		fields["description"] = jiraADFDocumentFromPlainText(description)
	}
	return body, nil
}

func jiraADFDocumentFromPlainText(text string) map[string]any {
	lines := strings.Split(text, "\n")
	content := make([]map[string]any, 0, len(lines))
	for _, line := range lines {
		paragraph := map[string]any{
			"type": "paragraph",
		}
		if line != "" {
			paragraph["content"] = []map[string]any{{
				"type": "text",
				"text": line,
			}}
		}
		content = append(content, paragraph)
	}
	if len(content) == 0 {
		content = append(content, map[string]any{"type": "paragraph"})
	}
	return map[string]any{
		"type":    "doc",
		"version": 1,
		"content": content,
	}
}

func jiraCreateIssueErrorAllowsAssigneeRetry(err error) bool {
	if err == nil {
		return false
	}
	msg := strings.ToLower(err.Error())
	return strings.Contains(msg, "assignee") && strings.Contains(msg, "cannot be assigned")
}

func cloneJiraIssueForCreate(issue *jira.Issue) *jira.Issue {
	if issue == nil {
		return nil
	}
	cloned := *issue
	if issue.Fields != nil {
		fields := *issue.Fields
		cloned.Fields = &fields
	}
	return &cloned
}

func jiraIssueCreateLogSummary(issue *jira.Issue) map[string]any {
	if issue == nil || issue.Fields == nil {
		return nil
	}
	fields := issue.Fields
	summary := map[string]any{
		"project":            fields.Project.Key,
		"issue_type":         fields.Type.Name,
		"summary":            fields.Summary,
		"description_length": len(fields.Description),
		"labels":             fields.Labels,
		"custom_fields":      fields.Unknowns,
	}
	if dueDate := time.Time(fields.Duedate); !dueDate.IsZero() {
		summary["due_date"] = dueDate.Format("2006-01-02")
	}
	if fields.Priority != nil {
		summary["priority"] = fields.Priority.Name
	}
	if fields.Assignee != nil {
		summary["assignee_account_id"] = fields.Assignee.AccountID
		summary["assignee_name"] = fields.Assignee.Name
	}
	return summary
}

func (c *jiraClient) CreateIssueLink(ctx context.Context, linkType, inwardIssueKey, outwardIssueKey string) error {
	body := map[string]any{
		"type": map[string]string{
			"name": linkType,
		},
		"inwardIssue": map[string]string{
			"key": inwardIssueKey,
		},
		"outwardIssue": map[string]string{
			"key": outwardIssueKey,
		},
	}
	_, err := c.jiraResty.R().
		SetContext(ctx).
		SetBody(body).
		Post("/rest/api/2/issueLink")
	if err != nil {
		return fmt.Errorf("create jira issue link failed: %s", err)
	}
	return nil
}

func (c *jiraClient) BindLarkGroupToIssue(ctx context.Context, issueID, groupID string) error {
	body := map[string]string{
		"issueId":       issueID,
		"feiShuGroupId": groupID,
	}
	resp, err := resty.New().R().
		SetBody(body).
		Post(c.conf.LarkGroupPluginBindURL)
	if err != nil {
		return fmt.Errorf("bind lark group failed: %s", err)
	}
	if !resp.IsSuccess() {
		return fmt.Errorf("bind lark group failed, code: %d, body: %s", resp.StatusCode(), resp.String())
	}

	return nil
}

func (c *jiraClient) GetBindingLarkGroupOfIssue(ctx context.Context, issueID string) (string, error) {
	body := map[string]string{
		"issueId": issueID,
	}
	result := map[string]string{}
	resp, err := resty.New().R().
		SetBody(body).
		SetResult(&result).
		Post(c.conf.LarkGroupPluginQueryURL)
	if err != nil {
		return "", fmt.Errorf("query binding lark group failed: %s", err)
	}
	if !resp.IsSuccess() {
		return "", fmt.Errorf("query binding lark group failed, code: %d, body: %s", resp.StatusCode(), resp.String())
	}
	bindedLarkGroup := result["data"]

	if bindedLarkGroup == "undefined" || bindedLarkGroup == "" {
		return "", nil
	}

	if !strings.HasPrefix(bindedLarkGroup, "oc_") {
		return "", fmt.Errorf("binding lark group id is invalid: %s", bindedLarkGroup)
	}

	return bindedLarkGroup, nil
}

func (c *jiraClient) SearchIssues(ctx context.Context, jql string, options *SearchOptions) (*SearchResults, error) {
	path := "/rest/api/2/search/jql"

	result := &SearchResults{}

	query := map[string]string{
		"jql": jql,
	}
	if options != nil {
		if options.NextPageToken != "" {
			query["nextPageToken"] = options.NextPageToken
		}
		if options.MaxResults > 0 {
			query["maxResults"] = strconv.Itoa(options.MaxResults)
		}
		if len(options.Fields) > 0 {
			query["fields"] = strings.Join(options.Fields, ",")
		}
		if options.Expand != "" {
			query["expand"] = options.Expand
		}
		if options.FailFast {
			query["failFast"] = "true"
		}
	}

	_, err := c.jiraResty.R().
		SetContext(ctx).
		SetQueryParams(query).
		SetResult(result).
		Get(path)
	if err != nil {
		return nil, fmt.Errorf("search issues failed: %s", err)
	}

	return result, nil
}

func (c *jiraClient) GetAssetObject(ctx context.Context, workspaceID, objectID string) (*AssetObject, error) {
	path := "/jsm/assets/workspace/{workspaceId}/v1/object/{id}"

	result := &AssetObject{}
	_, err := c.jsmResty.R().
		SetPathParam("workspaceId", workspaceID).
		SetPathParam("id", objectID).
		SetResult(result).
		Get(path)
	if err != nil {
		return nil, fmt.Errorf("get asset object failed: %s", err)
	}

	return result, nil
}
