//go:generate ${GOBIN}/mockgen -destination client_mock.go -package=fidcli ${GO_MODULE}/pkg/infra/clients/fidcli Client
package fidcli

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

	"github.com/google/go-github/v54/github"
	"github.com/tidbcloud/devops/pkg/infra/config"
	"golang.org/x/oauth2"
)

type DeliveryStatus string

const (
	DeliveryStatusRunning   DeliveryStatus = "running"
	DeliveryStatusSucceeded DeliveryStatus = "succeeded"
	DeliveryStatusFailed    DeliveryStatus = "failed"
	fidResultActor                         = "github-actions[bot]"
	queueIssueLookupLimit                  = 10
)

type SubmitRequest struct {
	Environment   string
	Images        []string
	CorrelationID string
	RequestedAt   time.Time
}

type Submission struct {
	IssueNumber int
	CommentID   int64
	CommentURL  string
	CreatedAt   time.Time
}

type DeliveryResult struct {
	Status           DeliveryStatus
	ResultCommentURL string
	Images           []string
	TargetImages     []string
}

type Client interface {
	Submit(ctx context.Context, request SubmitRequest) (*Submission, error)
	GetResult(ctx context.Context, submission Submission, correlationID string) (*DeliveryResult, error)
	StartImageAvailabilityQuery(ctx context.Context, imageRef string) (*ImageAvailabilityQuery, error)
	GetImageAvailabilityQueryResult(ctx context.Context, query ImageAvailabilityQuery) (*ImageAvailability, bool, error)
	QueryImageAvailability(ctx context.Context, imageRef string) (*ImageAvailability, error)
}

type client struct {
	github             *github.Client
	downloadHTTPClient *http.Client
	owner              string
	repo               string
	queueLabelPrefix   string
	queueCommentLimit  int
	submitMu           sync.Mutex
}

func NewClient(conf *config.RootConfig) (Client, error) {
	fidConf := conf.Services.KernelImage.FID
	if !conf.Services.KernelImage.Enabled {
		return &client{}, nil
	}
	if strings.TrimSpace(fidConf.RepositoryOwner) == "" || strings.TrimSpace(fidConf.RepositoryName) == "" {
		return nil, fmt.Errorf("kernel image FID repository owner and name are required")
	}
	if strings.TrimSpace(fidConf.QueueLabelPrefix) == "" {
		return nil, fmt.Errorf("kernel image FID queue label prefix is required")
	}
	if fidConf.QueueCommentLimit <= 0 {
		return nil, fmt.Errorf("kernel image FID queue comment limit must be positive")
	}
	if strings.TrimSpace(conf.Clients.Github.Token) == "" {
		return nil, fmt.Errorf("GitHub token is required for kernel image FID")
	}

	source := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: conf.Clients.Github.Token})
	githubHTTPClient := oauth2.NewClient(context.Background(), source)
	githubHTTPClient.Timeout = 10 * time.Second
	return &client{
		github:             github.NewClient(githubHTTPClient),
		downloadHTTPClient: &http.Client{Timeout: 10 * time.Second},
		owner:              fidConf.RepositoryOwner,
		repo:               fidConf.RepositoryName,
		queueLabelPrefix:   fidConf.QueueLabelPrefix,
		queueCommentLimit:  fidConf.QueueCommentLimit,
	}, nil
}

func (c *client) Submit(ctx context.Context, request SubmitRequest) (*Submission, error) {
	if c.github == nil {
		return nil, fmt.Errorf("FID client is disabled")
	}
	if len(request.Images) == 0 {
		return nil, fmt.Errorf("FID images are required")
	}
	for _, image := range request.Images {
		if strings.TrimSpace(image) == "" {
			return nil, fmt.Errorf("FID image must not be empty")
		}
	}
	c.submitMu.Lock()
	defer c.submitMu.Unlock()

	label := fmt.Sprintf("%s/%s", c.queueLabelPrefix, request.Environment)
	activeIssues, err := c.listQueueIssues(ctx, label, "open", queueIssueLookupLimit)
	if err != nil {
		return nil, err
	}
	activeIssue, err := c.reconcileOpenQueueIssues(ctx, activeIssues)
	if err != nil {
		return nil, err
	}

	searchSince := requestCommentSearchSince(request.RequestedAt)
	if existing, issueNumber, err := c.findRequestInIssues(ctx, activeIssues, request.CorrelationID, searchSince); err != nil {
		return nil, err
	} else if existing != nil {
		return submissionFromComment(issueNumber, existing), nil
	}

	if shouldSearchClosedQueues(activeIssue, request.RequestedAt) {
		closedIssues, err := c.listQueueIssues(ctx, label, "closed", queueIssueLookupLimit)
		if err != nil {
			return nil, err
		}
		if existing, issueNumber, err := c.findRequestInIssues(ctx, closedIssues, request.CorrelationID, searchSince); err != nil {
			return nil, err
		} else if existing != nil {
			return submissionFromComment(issueNumber, existing), nil
		}
	}

	if activeIssue != nil && activeIssue.GetComments() >= c.queueCommentLimit {
		if err := c.closeQueueIssue(ctx, activeIssue); err != nil {
			return nil, err
		}
		activeIssue = nil
	}
	if activeIssue == nil {
		activeIssue, activeIssues, err = c.createAndReconcileQueueIssue(ctx, label, request.Environment)
		if err != nil {
			return nil, err
		}
		if existing, issueNumber, err := c.findRequestInIssues(ctx, activeIssues, request.CorrelationID, searchSince); err != nil {
			return nil, err
		} else if existing != nil {
			return submissionFromComment(issueNumber, existing), nil
		}
	}

	body := BuildCommand(request)
	comment, _, err := c.github.Issues.CreateComment(ctx, c.owner, c.repo, activeIssue.GetNumber(), &github.IssueComment{Body: github.String(body)})
	if err != nil {
		return nil, fmt.Errorf("post FID command: %w", err)
	}
	return submissionFromComment(activeIssue.GetNumber(), comment), nil
}

func (c *client) findRequestInIssues(
	ctx context.Context,
	issues []*github.Issue,
	correlationID string,
	since *time.Time,
) (*github.IssueComment, int, error) {
	for _, issue := range issues {
		if issue.IsPullRequest() {
			continue
		}
		comment, err := c.findRequestComment(ctx, issue.GetNumber(), correlationID, since)
		if err != nil {
			return nil, 0, err
		}
		if comment != nil {
			return comment, issue.GetNumber(), nil
		}
	}
	return nil, 0, nil
}

func (c *client) GetResult(ctx context.Context, submission Submission, correlationID string) (*DeliveryResult, error) {
	if c.github == nil {
		return nil, fmt.Errorf("FID client is disabled")
	}
	reactions, _, err := c.github.Reactions.ListIssueCommentReactions(
		ctx,
		c.owner,
		c.repo,
		submission.CommentID,
		&github.ListOptions{PerPage: 100},
	)
	if err != nil {
		return nil, fmt.Errorf("list FID request reactions: %w", err)
	}

	status := deliveryStatusFromReactions(reactions)
	if status == DeliveryStatusRunning {
		return &DeliveryResult{Status: status}, nil
	}

	resultComment, err := c.findResultComment(ctx, submission.IssueNumber, submission.CreatedAt, correlationID)
	if err != nil {
		return nil, err
	}
	// FID can publish the terminal reaction before its result comment becomes visible.
	if status == DeliveryStatusSucceeded && resultComment == nil {
		return &DeliveryResult{Status: DeliveryStatusRunning}, nil
	}
	result := &DeliveryResult{Status: status}
	if resultComment != nil {
		result.ResultCommentURL = resultComment.GetHTMLURL()
		metadata, err := parseResultMetadata(resultComment.GetBody())
		if err != nil {
			return nil, fmt.Errorf("parse FID result metadata: %w", err)
		}
		result.Images = metadata.Images
		result.TargetImages = metadata.TargetImages
	}
	return result, nil
}

type resultMetadata struct {
	Images       []string `json:"images"`
	TargetImages []string `json:"target_images"`
}

func parseResultMetadata(body string) (*resultMetadata, error) {
	const prefix = "<!-- META="
	start := strings.Index(body, prefix)
	if start < 0 {
		return nil, fmt.Errorf("META marker is missing")
	}
	payload := body[start+len(prefix):]
	end := strings.Index(payload, "-->")
	if end < 0 {
		return nil, fmt.Errorf("META marker is not closed")
	}
	metadata := &resultMetadata{}
	if err := json.Unmarshal([]byte(strings.TrimSpace(payload[:end])), metadata); err != nil {
		return nil, err
	}
	return metadata, nil
}

func BuildCommand(request SubmitRequest) string {
	return fmt.Sprintf(
		"/fid env=%s cid=%s\n%s",
		request.Environment,
		request.CorrelationID,
		strings.Join(request.Images, "\n"),
	)
}

func (c *client) ensureLabel(ctx context.Context, label string) error {
	if _, _, err := c.github.Issues.GetLabel(ctx, c.owner, c.repo, label); err == nil {
		return nil
	} else if !isNotFound(err) {
		return fmt.Errorf("get FID queue label: %w", err)
	}

	_, _, err := c.github.Issues.CreateLabel(ctx, c.owner, c.repo, &github.Label{
		Name:  github.String(label),
		Color: github.String("5319e7"),
	})
	if err == nil || isUnprocessableEntity(err) {
		return nil
	}
	return fmt.Errorf("ensure FID queue label: %w", err)
}

func (c *client) listQueueIssues(
	ctx context.Context,
	label, state string,
	limit int,
) ([]*github.Issue, error) {
	issues, _, err := c.github.Issues.ListByRepo(ctx, c.owner, c.repo, &github.IssueListByRepoOptions{
		State:       state,
		Labels:      []string{label},
		Sort:        "created",
		Direction:   "desc",
		ListOptions: github.ListOptions{PerPage: limit},
	})
	if err != nil {
		return nil, fmt.Errorf("list %s FID queue issues: %w", state, err)
	}
	return issues, nil
}

func (c *client) reconcileOpenQueueIssues(ctx context.Context, issues []*github.Issue) (*github.Issue, error) {
	newest := newestQueueIssue(issues)
	if newest == nil {
		return nil, nil
	}
	for _, issue := range issues {
		if issue.IsPullRequest() || issue.GetNumber() == newest.GetNumber() {
			continue
		}
		if err := c.closeQueueIssue(ctx, issue); err != nil {
			return nil, err
		}
	}
	return newest, nil
}

func newestQueueIssue(issues []*github.Issue) *github.Issue {
	var newest *github.Issue
	for _, issue := range issues {
		if issue.IsPullRequest() {
			continue
		}
		if newest == nil || issue.GetNumber() > newest.GetNumber() {
			newest = issue
		}
	}
	return newest
}

func (c *client) createAndReconcileQueueIssue(
	ctx context.Context,
	label, environment string,
) (*github.Issue, []*github.Issue, error) {
	if err := c.ensureLabel(ctx, label); err != nil {
		return nil, nil, err
	}

	title := fmt.Sprintf("Image delivery queue (tidbx) - %s - %s", environment, time.Now().UTC().Format(time.DateOnly))
	body := fmt.Sprintf(
		"This issue tracks image delivery requests (fid) for **%s** environment.\n"+
			"Each delivery request is posted as a comment below.\n\n"+
			"When comments reach %d, this issue is auto-closed and a new one is created.",
		environment,
		c.queueCommentLimit,
	)
	issue, _, err := c.github.Issues.Create(ctx, c.owner, c.repo, &github.IssueRequest{
		Title:  github.String(title),
		Body:   github.String(body),
		Labels: &[]string{label},
	})
	if err != nil {
		return nil, nil, fmt.Errorf("create FID queue issue: %w", err)
	}

	issues, err := c.listQueueIssues(ctx, label, "open", queueIssueLookupLimit)
	if err != nil {
		return nil, nil, err
	}
	issues = appendIssueIfMissing(issues, issue)
	activeIssue, err := c.reconcileOpenQueueIssues(ctx, issues)
	if err != nil {
		return nil, nil, err
	}
	return activeIssue, issues, nil
}

func appendIssueIfMissing(issues []*github.Issue, issue *github.Issue) []*github.Issue {
	for _, candidate := range issues {
		if candidate.GetNumber() == issue.GetNumber() {
			return issues
		}
	}
	return append(issues, issue)
}

func (c *client) closeQueueIssue(ctx context.Context, issue *github.Issue) error {
	state := "closed"
	if _, _, err := c.github.Issues.Edit(ctx, c.owner, c.repo, issue.GetNumber(), &github.IssueRequest{State: &state}); err != nil {
		return fmt.Errorf("close superseded FID queue issue %d: %w", issue.GetNumber(), err)
	}
	return nil
}

func (c *client) findRequestComment(
	ctx context.Context,
	issueNumber int,
	correlationID string,
	since *time.Time,
) (*github.IssueComment, error) {
	comments, err := c.listComments(ctx, issueNumber, since, "asc")
	if err != nil {
		return nil, err
	}
	marker := "cid=" + correlationID
	for _, comment := range comments {
		body := strings.TrimSpace(comment.GetBody())
		if strings.HasPrefix(body, "/fid ") && strings.Contains(strings.SplitN(body, "\n", 2)[0], marker) {
			return comment, nil
		}
	}
	return nil, nil
}

func requestCommentSearchSince(requestedAt time.Time) *time.Time {
	if requestedAt.IsZero() {
		return nil
	}
	since := requestedAt.UTC().Add(-time.Second)
	return &since
}

func shouldSearchClosedQueues(activeIssue *github.Issue, requestedAt time.Time) bool {
	if requestedAt.IsZero() {
		return false
	}
	if activeIssue == nil {
		return true
	}
	activeCreatedAt := activeIssue.GetCreatedAt().Time
	return activeCreatedAt.IsZero() || !activeCreatedAt.Before(requestedAt)
}

func (c *client) findResultComment(
	ctx context.Context,
	issueNumber int,
	since time.Time,
	correlationID string,
) (*github.IssueComment, error) {
	comments, err := c.listComments(ctx, issueNumber, &since, "desc")
	if err != nil {
		return nil, err
	}
	marker := `"correlation_id":"` + correlationID + `"`
	for _, comment := range comments {
		if resultCommentMatches(comment.GetBody(), marker) {
			return comment, nil
		}
	}
	return nil, nil
}

func deliveryStatusFromReactions(reactions []*github.Reaction) DeliveryStatus {
	status := DeliveryStatusRunning
	for _, reaction := range reactions {
		if reaction.GetUser().GetLogin() != fidResultActor {
			continue
		}
		switch reaction.GetContent() {
		case "confused", "-1":
			return DeliveryStatusFailed
		case "hooray":
			status = DeliveryStatusSucceeded
		}
	}
	return status
}

func resultCommentMatches(body, correlationMarker string) bool {
	return strings.Contains(body, "<!-- META=") && strings.Contains(body, correlationMarker)
}

func (c *client) listComments(ctx context.Context, issueNumber int, since *time.Time, direction string) ([]*github.IssueComment, error) {
	options := &github.IssueListCommentsOptions{
		Sort:        github.String("created"),
		Direction:   github.String(direction),
		Since:       since,
		ListOptions: github.ListOptions{PerPage: 100},
	}
	var comments []*github.IssueComment
	for {
		page, response, err := c.github.Issues.ListComments(ctx, c.owner, c.repo, issueNumber, options)
		if err != nil {
			return nil, fmt.Errorf("list comments for FID queue issue %d: %w", issueNumber, err)
		}
		comments = append(comments, page...)
		if response.NextPage == 0 {
			return comments, nil
		}
		options.Page = response.NextPage
	}
}

func submissionFromComment(issueNumber int, comment *github.IssueComment) *Submission {
	return &Submission{
		IssueNumber: issueNumber,
		CommentID:   comment.GetID(),
		CommentURL:  comment.GetHTMLURL(),
		CreatedAt:   comment.GetCreatedAt().Time,
	}
}

func isUnprocessableEntity(err error) bool {
	var responseError *github.ErrorResponse
	return errors.As(err, &responseError) && responseError.Response != nil && responseError.Response.StatusCode == http.StatusUnprocessableEntity
}

func isNotFound(err error) bool {
	var responseError *github.ErrorResponse
	return errors.As(err, &responseError) && responseError.Response != nil && responseError.Response.StatusCode == http.StatusNotFound
}
