// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package migrations

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

	"forgejo.org/modules/log"
	base "forgejo.org/modules/migration"
	"forgejo.org/modules/proxy"
	"forgejo.org/modules/structs"
)

var (
	_ base.Downloader        = &CodebaseDownloader{}
	_ base.DownloaderFactory = &CodebaseDownloaderFactory{}
)

func init() {
	RegisterDownloaderFactory(&CodebaseDownloaderFactory{})
}

// CodebaseDownloaderFactory defines a downloader factory
type CodebaseDownloaderFactory struct{}

// New returns a downloader related to this factory according MigrateOptions
func (f *CodebaseDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) {
	u, err := url.Parse(opts.CloneAddr)
	if err != nil {
		return nil, err
	}
	u.User = nil

	fields := strings.Split(strings.Trim(u.Path, "/"), "/")
	if len(fields) != 2 {
		return nil, fmt.Errorf("invalid path: %s", u.Path)
	}
	project := fields[0]
	repoName := strings.TrimSuffix(fields[1], ".git")

	log.Trace("Create Codebase downloader. BaseURL: %v RepoName: %s", u, repoName)

	return NewCodebaseDownloader(ctx, u, project, repoName, opts.AuthUsername, opts.AuthPassword), nil
}

// GitServiceType returns the type of git service
func (f *CodebaseDownloaderFactory) GitServiceType() structs.GitServiceType {
	return structs.CodebaseService
}

type codebaseUser struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

// CodebaseDownloader implements a Downloader interface to get repository information
// from Codebase
type CodebaseDownloader struct {
	base.NullDownloader
	ctx           context.Context
	client        *http.Client
	baseURL       *url.URL
	projectURL    *url.URL
	project       string
	repoName      string
	maxIssueIndex int64
	userMap       map[int64]*codebaseUser
	commitMap     map[string]string
}

// SetContext set context
func (d *CodebaseDownloader) SetContext(ctx context.Context) {
	d.ctx = ctx
}

// NewCodebaseDownloader creates a new downloader
func NewCodebaseDownloader(ctx context.Context, projectURL *url.URL, project, repoName, username, password string) *CodebaseDownloader {
	baseURL, _ := url.Parse("https://api3.codebasehq.com")

	downloader := &CodebaseDownloader{
		ctx:        ctx,
		baseURL:    baseURL,
		projectURL: projectURL,
		project:    project,
		repoName:   repoName,
		client: &http.Client{
			Transport: &http.Transport{
				Proxy: func(req *http.Request) (*url.URL, error) {
					if len(username) > 0 && len(password) > 0 {
						req.SetBasicAuth(username, password)
					}
					return proxy.Proxy()(req)
				},
			},
		},
		userMap:   make(map[int64]*codebaseUser),
		commitMap: make(map[string]string),
	}

	log.Trace("Create Codebase downloader. BaseURL: %s Project: %s RepoName: %s", baseURL, project, repoName)
	return downloader
}

// String implements Stringer
func (d *CodebaseDownloader) String() string {
	return fmt.Sprintf("migration from codebase server %s %s/%s", d.baseURL, d.project, d.repoName)
}

func (d *CodebaseDownloader) LogString() string {
	if d == nil {
		return "<CodebaseDownloader nil>"
	}
	return fmt.Sprintf("<CodebaseDownloader %s %s/%s>", d.baseURL, d.project, d.repoName)
}

// FormatCloneURL add authentication into remote URLs
func (d *CodebaseDownloader) FormatCloneURL(opts base.MigrateOptions, remoteAddr string) (string, error) {
	return opts.CloneAddr, nil
}

func (d *CodebaseDownloader) callAPI(endpoint string, parameter map[string]string, result any) error {
	u, err := d.baseURL.Parse(endpoint)
	if err != nil {
		return err
	}

	if parameter != nil {
		query := u.Query()
		for k, v := range parameter {
			query.Set(k, v)
		}
		u.RawQuery = query.Encode()
	}

	req, err := http.NewRequestWithContext(d.ctx, "GET", u.String(), nil)
	if err != nil {
		return err
	}
	req.Header.Add("Accept", "application/xml")

	resp, err := d.client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	return xml.NewDecoder(resp.Body).Decode(&result)
}

// GetRepoInfo returns repository information
// https://support.codebasehq.com/kb/projects
func (d *CodebaseDownloader) GetRepoInfo() (*base.Repository, error) {
	var rawRepository struct {
		XMLName     xml.Name `xml:"repository"`
		Name        string   `xml:"name"`
		Description string   `xml:"description"`
		Permalink   string   `xml:"permalink"`
		CloneURL    string   `xml:"clone-url"`
		Source      string   `xml:"source"`
	}

	err := d.callAPI(
		fmt.Sprintf("/%s/%s", d.project, d.repoName),
		nil,
		&rawRepository,
	)
	if err != nil {
		return nil, err
	}

	return &base.Repository{
		Name:        rawRepository.Name,
		Description: rawRepository.Description,
		CloneURL:    rawRepository.CloneURL,
		OriginalURL: d.projectURL.String(),
	}, nil
}

// GetMilestones returns milestones
// https://support.codebasehq.com/kb/tickets-and-milestones/milestones
func (d *CodebaseDownloader) GetMilestones() ([]*base.Milestone, error) {
	var rawMilestones struct {
		XMLName            xml.Name `xml:"ticketing-milestone"`
		Type               string   `xml:"type,attr"`
		TicketingMilestone []struct {
			Text string `xml:",chardata"`
			ID   struct {
				Value int64  `xml:",chardata"`
				Type  string `xml:"type,attr"`
			} `xml:"id"`
			Identifier string `xml:"identifier"`
			Name       string `xml:"name"`
			Deadline   struct {
				Value string `xml:",chardata"`
				Type  string `xml:"type,attr"`
			} `xml:"deadline"`
			Description string `xml:"description"`
			Status      string `xml:"status"`
		} `xml:"ticketing-milestone"`
	}

	err := d.callAPI(
		fmt.Sprintf("/%s/milestones", d.project),
		nil,
		&rawMilestones,
	)
	if err != nil {
		return nil, err
	}

	milestones := make([]*base.Milestone, 0, len(rawMilestones.TicketingMilestone))
	for _, milestone := range rawMilestones.TicketingMilestone {
		var deadline *time.Time
		if len(milestone.Deadline.Value) > 0 {
			if val, err := time.Parse("2006-01-02", milestone.Deadline.Value); err == nil {
				deadline = &val
			}
		}

		closed := deadline
		state := "closed"
		if milestone.Status == "active" {
			closed = nil
			state = ""
		}

		milestones = append(milestones, &base.Milestone{
			Title:    milestone.Name,
			Deadline: deadline,
			Closed:   closed,
			State:    state,
		})
	}
	return milestones, nil
}

// GetLabels returns labels
// https://support.codebasehq.com/kb/tickets-and-milestones/statuses-priorities-and-categories
func (d *CodebaseDownloader) GetLabels() ([]*base.Label, error) {
	var rawTypes struct {
		XMLName       xml.Name `xml:"ticketing-types"`
		Type          string   `xml:"type,attr"`
		TicketingType []struct {
			ID struct {
				Value int64  `xml:",chardata"`
				Type  string `xml:"type,attr"`
			} `xml:"id"`
			Name string `xml:"name"`
		} `xml:"ticketing-type"`
	}

	err := d.callAPI(
		fmt.Sprintf("/%s/tickets/types", d.project),
		nil,
		&rawTypes,
	)
	if err != nil {
		return nil, err
	}

	labels := make([]*base.Label, 0, len(rawTypes.TicketingType))
	for _, label := range rawTypes.TicketingType {
		labels = append(labels, &base.Label{
			Name:  label.Name,
			Color: "ffffff",
		})
	}
	return labels, nil
}

type codebaseIssueContext struct {
	Comments []*base.Comment
}

// GetIssues returns issues, limits are not supported
// https://support.codebasehq.com/kb/tickets-and-milestones
// https://support.codebasehq.com/kb/tickets-and-milestones/updating-tickets
func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
	var rawIssues struct {
		XMLName xml.Name `xml:"tickets"`
		Type    string   `xml:"type,attr"`
		Ticket  []struct {
			TicketID struct {
				Value int64  `xml:",chardata"`
				Type  string `xml:"type,attr"`
			} `xml:"ticket-id"`
			Summary    string `xml:"summary"`
			TicketType string `xml:"ticket-type"`
			ReporterID struct {
				Value int64  `xml:",chardata"`
				Type  string `xml:"type,attr"`
			} `xml:"reporter-id"`
			Reporter string `xml:"reporter"`
			Type     struct {
				Name string `xml:"name"`
			} `xml:"type"`
			Status struct {
				TreatAsClosed struct {
					Value bool   `xml:",chardata"`
					Type  string `xml:"type,attr"`
				} `xml:"treat-as-closed"`
			} `xml:"status"`
			Milestone struct {
				Name string `xml:"name"`
			} `xml:"milestone"`
			UpdatedAt struct {
				Value time.Time `xml:",chardata"`
				Type  string    `xml:"type,attr"`
			} `xml:"updated-at"`
			CreatedAt struct {
				Value time.Time `xml:",chardata"`
				Type  string    `xml:"type,attr"`
			} `xml:"created-at"`
		} `xml:"ticket"`
	}

	err := d.callAPI(
		fmt.Sprintf("/%s/tickets", d.project),
		nil,
		&rawIssues,
	)
	if err != nil {
		return nil, false, err
	}

	issues := make([]*base.Issue, 0, len(rawIssues.Ticket))
	for _, issue := range rawIssues.Ticket {
		var notes struct {
			XMLName    xml.Name `xml:"ticket-notes"`
			Type       string   `xml:"type,attr"`
			TicketNote []struct {
				Content   string `xml:"content"`
				CreatedAt struct {
					Value time.Time `xml:",chardata"`
					Type  string    `xml:"type,attr"`
				} `xml:"created-at"`
				UpdatedAt struct {
					Value time.Time `xml:",chardata"`
					Type  string    `xml:"type,attr"`
				} `xml:"updated-at"`
				ID struct {
					Value int64  `xml:",chardata"`
					Type  string `xml:"type,attr"`
				} `xml:"id"`
				UserID struct {
					Value int64  `xml:",chardata"`
					Type  string `xml:"type,attr"`
				} `xml:"user-id"`
			} `xml:"ticket-note"`
		}
		err := d.callAPI(
			fmt.Sprintf("/%s/tickets/%d/notes", d.project, issue.TicketID.Value),
			nil,
			&notes,
		)
		if err != nil {
			return nil, false, err
		}
		comments := make([]*base.Comment, 0, len(notes.TicketNote))
		for _, note := range notes.TicketNote {
			if len(note.Content) == 0 {
				continue
			}
			poster := d.tryGetUser(note.UserID.Value)
			comments = append(comments, &base.Comment{
				IssueIndex:  issue.TicketID.Value,
				Index:       note.ID.Value,
				PosterID:    poster.ID,
				PosterName:  poster.Name,
				PosterEmail: poster.Email,
				Content:     note.Content,
				Created:     note.CreatedAt.Value,
				Updated:     note.UpdatedAt.Value,
			})
		}
		if len(comments) == 0 {
			comments = append(comments, &base.Comment{})
		}

		state := "open"
		if issue.Status.TreatAsClosed.Value {
			state = "closed"
		}
		poster := d.tryGetUser(issue.ReporterID.Value)
		issues = append(issues, &base.Issue{
			Title:       issue.Summary,
			Number:      issue.TicketID.Value,
			PosterName:  poster.Name,
			PosterEmail: poster.Email,
			Content:     comments[0].Content,
			Milestone:   issue.Milestone.Name,
			State:       state,
			Created:     issue.CreatedAt.Value,
			Updated:     issue.UpdatedAt.Value,
			Labels: []*base.Label{
				{Name: issue.Type.Name},
			},
			ForeignIndex: issue.TicketID.Value,
			Context: codebaseIssueContext{
				Comments: comments[1:],
			},
		})

		if d.maxIssueIndex < issue.TicketID.Value {
			d.maxIssueIndex = issue.TicketID.Value
		}
	}

	return issues, true, nil
}

// GetComments returns comments
func (d *CodebaseDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
	context, ok := commentable.GetContext().(codebaseIssueContext)
	if !ok {
		return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext())
	}

	return context.Comments, true, nil
}

// GetPullRequests returns pull requests
// https://support.codebasehq.com/kb/repositories/merge-requests
func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
	var rawMergeRequests struct {
		XMLName      xml.Name `xml:"merge-requests"`
		Type         string   `xml:"type,attr"`
		MergeRequest []struct {
			ID struct {
				Value int64  `xml:",chardata"`
				Type  string `xml:"type,attr"`
			} `xml:"id"`
		} `xml:"merge-request"`
	}

	err := d.callAPI(
		fmt.Sprintf("/%s/%s/merge_requests", d.project, d.repoName),
		map[string]string{
			"query":  `"Target Project" is "` + d.repoName + `"`,
			"offset": strconv.Itoa((page - 1) * perPage),
			"count":  strconv.Itoa(perPage),
		},
		&rawMergeRequests,
	)
	if err != nil {
		return nil, false, err
	}

	pullRequests := make([]*base.PullRequest, 0, len(rawMergeRequests.MergeRequest))
	for i, mr := range rawMergeRequests.MergeRequest {
		var rawMergeRequest struct {
			XMLName xml.Name `xml:"merge-request"`
			ID      struct {
				Value int64  `xml:",chardata"`
				Type  string `xml:"type,attr"`
			} `xml:"id"`
			SourceRef string `xml:"source-ref"` // NOTE: from the documentation these are actually just branches NOT full refs
			TargetRef string `xml:"target-ref"` // NOTE: from the documentation these are actually just branches NOT full refs
			Subject   string `xml:"subject"`
			Status    string `xml:"status"`
			UserID    struct {
				Value int64  `xml:",chardata"`
				Type  string `xml:"type,attr"`
			} `xml:"user-id"`
			CreatedAt struct {
				Value time.Time `xml:",chardata"`
				Type  string    `xml:"type,attr"`
			} `xml:"created-at"`
			UpdatedAt struct {
				Value time.Time `xml:",chardata"`
				Type  string    `xml:"type,attr"`
			} `xml:"updated-at"`
			Comments struct {
				Type    string `xml:"type,attr"`
				Comment []struct {
					Content string `xml:"content"`
					ID      struct {
						Value int64  `xml:",chardata"`
						Type  string `xml:"type,attr"`
					} `xml:"id"`
					UserID struct {
						Value int64  `xml:",chardata"`
						Type  string `xml:"type,attr"`
					} `xml:"user-id"`
					Action struct {
						Value string `xml:",chardata"`
						Nil   string `xml:"nil,attr"`
					} `xml:"action"`
					CreatedAt struct {
						Value time.Time `xml:",chardata"`
						Type  string    `xml:"type,attr"`
					} `xml:"created-at"`
				} `xml:"comment"`
			} `xml:"comments"`
		}
		err := d.callAPI(
			fmt.Sprintf("/%s/%s/merge_requests/%d", d.project, d.repoName, mr.ID.Value),
			nil,
			&rawMergeRequest,
		)
		if err != nil {
			return nil, false, err
		}

		number := d.maxIssueIndex + int64(i) + 1

		state := "open"
		merged := false
		var closeTime *time.Time
		var mergedTime *time.Time
		if rawMergeRequest.Status != "new" {
			state = "closed"
			closeTime = &rawMergeRequest.UpdatedAt.Value
		}

		comments := make([]*base.Comment, 0, len(rawMergeRequest.Comments.Comment))
		for _, comment := range rawMergeRequest.Comments.Comment {
			if len(comment.Content) == 0 {
				if comment.Action.Value == "merging" {
					merged = true
					mergedTime = &comment.CreatedAt.Value
				}
				continue
			}
			poster := d.tryGetUser(comment.UserID.Value)
			comments = append(comments, &base.Comment{
				IssueIndex:  number,
				Index:       comment.ID.Value,
				PosterID:    poster.ID,
				PosterName:  poster.Name,
				PosterEmail: poster.Email,
				Content:     comment.Content,
				Created:     comment.CreatedAt.Value,
				Updated:     comment.CreatedAt.Value,
			})
		}
		if len(comments) == 0 {
			comments = append(comments, &base.Comment{})
		}

		poster := d.tryGetUser(rawMergeRequest.UserID.Value)

		pullRequests = append(pullRequests, &base.PullRequest{
			Title:       rawMergeRequest.Subject,
			Number:      number,
			PosterName:  poster.Name,
			PosterEmail: poster.Email,
			Content:     comments[0].Content,
			State:       state,
			Created:     rawMergeRequest.CreatedAt.Value,
			Updated:     rawMergeRequest.UpdatedAt.Value,
			Closed:      closeTime,
			Merged:      merged,
			MergedTime:  mergedTime,
			Head: base.PullRequestBranch{
				Ref:      rawMergeRequest.SourceRef,
				SHA:      d.getHeadCommit(rawMergeRequest.SourceRef),
				RepoName: d.repoName,
			},
			Base: base.PullRequestBranch{
				Ref:      rawMergeRequest.TargetRef,
				SHA:      d.getHeadCommit(rawMergeRequest.TargetRef),
				RepoName: d.repoName,
			},
			ForeignIndex: rawMergeRequest.ID.Value,
			Context: codebaseIssueContext{
				Comments: comments[1:],
			},
		})

		// SECURITY: Ensure that the PR is safe
		_ = CheckAndEnsureSafePR(pullRequests[len(pullRequests)-1], d.baseURL.String(), d)
	}

	return pullRequests, true, nil
}

func (d *CodebaseDownloader) tryGetUser(userID int64) *codebaseUser {
	if len(d.userMap) == 0 {
		var rawUsers struct {
			XMLName xml.Name `xml:"users"`
			Type    string   `xml:"type,attr"`
			User    []struct {
				EmailAddress string `xml:"email-address"`
				ID           struct {
					Value int64  `xml:",chardata"`
					Type  string `xml:"type,attr"`
				} `xml:"id"`
				LastName  string `xml:"last-name"`
				FirstName string `xml:"first-name"`
				Username  string `xml:"username"`
			} `xml:"user"`
		}

		err := d.callAPI(
			"/users",
			nil,
			&rawUsers,
		)
		if err == nil {
			for _, user := range rawUsers.User {
				d.userMap[user.ID.Value] = &codebaseUser{
					Name:  user.Username,
					Email: user.EmailAddress,
				}
			}
		}
	}

	user, ok := d.userMap[userID]
	if !ok {
		user = &codebaseUser{
			Name: fmt.Sprintf("User %d", userID),
		}
		d.userMap[userID] = user
	}

	return user
}

func (d *CodebaseDownloader) getHeadCommit(ref string) string {
	commitRef, ok := d.commitMap[ref]
	if !ok {
		var rawCommits struct {
			XMLName xml.Name `xml:"commits"`
			Type    string   `xml:"type,attr"`
			Commit  []struct {
				Ref string `xml:"ref"`
			} `xml:"commit"`
		}
		err := d.callAPI(
			fmt.Sprintf("/%s/%s/commits/%s", d.project, d.repoName, ref),
			nil,
			&rawCommits,
		)
		if err == nil && len(rawCommits.Commit) > 0 {
			commitRef = rawCommits.Commit[0].Ref
			d.commitMap[ref] = commitRef
		}
	}
	return commitRef
}
