package forgejo

import (
	"context"
	"fmt"
	"log/slog"
	"slices"
	"strings"

	"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
	"github.com/blang/semver/v4"
	"github.com/go-git/go-git/v5/plumbing/transport"
	"github.com/go-git/go-git/v5/plumbing/transport/http"

	"github.com/apricote/releaser-pleaser/internal/forge"
	"github.com/apricote/releaser-pleaser/internal/git"
	"github.com/apricote/releaser-pleaser/internal/pointer"
	"github.com/apricote/releaser-pleaser/internal/releasepr"
)

const ()

var _ forge.Forge = &Forgejo{}

type Forgejo struct {
	options *Options

	client *forgejo.Client
	log    *slog.Logger
}

func (f *Forgejo) RepoURL() string {
	return fmt.Sprintf("%s/%s/%s", f.options.APIURL, f.options.Owner, f.options.Repo)
}

func (f *Forgejo) CloneURL() string {
	return fmt.Sprintf("%s.git", f.RepoURL())
}

func (f *Forgejo) ReleaseURL(version string) string {
	return fmt.Sprintf("%s/releases/tag/%s", f.RepoURL(), version)
}

func (f *Forgejo) PullRequestURL(id int) string {
	return fmt.Sprintf("%s/pulls/%d", f.RepoURL(), id)
}

func (f *Forgejo) GitAuth() transport.AuthMethod {
	return &http.BasicAuth{
		Username: f.options.Username,
		Password: f.options.APIToken,
	}
}

func (f *Forgejo) CommitAuthor(ctx context.Context) (git.Author, error) {
	f.log.DebugContext(ctx, "getting commit author from current token user")

	user, _, err := f.client.GetMyUserInfo()
	if err != nil {
		return git.Author{}, err
	}

	// TODO: Same for other forges?
	name := user.FullName
	if name == "" {
		name = user.UserName
	}

	return git.Author{
		Name:  name,
		Email: user.Email,
	}, nil
}

func (f *Forgejo) LatestTags(ctx context.Context) (git.Releases, error) {
	f.log.DebugContext(ctx, "listing all tags in forgejo repository")

	tags, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Tag, *forgejo.Response, error) {
		return f.client.ListRepoTags(f.options.Owner, f.options.Repo,
			forgejo.ListRepoTagsOptions{ListOptions: listOptions},
		)
	})
	if err != nil {
		return git.Releases{}, err
	}

	var releases git.Releases

	for _, fTag := range tags {
		tag := &git.Tag{
			Hash: fTag.Commit.SHA,
			Name: fTag.Name,
		}

		version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
		if err != nil {
			f.log.WarnContext(
				ctx, "unable to parse tag as semver, skipping",
				"tag.name", tag.Name,
				"tag.hash", tag.Hash,
				"error", err,
			)
			continue
		}

		if releases.Latest == nil {
			releases.Latest = tag
		}
		if len(version.Pre) == 0 {
			// Stable version tag
			// We return once we have found the latest stable tag, not needed to look at every single tag.
			releases.Stable = tag
			break
		}
	}

	return releases, nil
}

func (f *Forgejo) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
	var repositoryCommits []*forgejo.Commit
	var err error
	if tag != nil {
		repositoryCommits, err = f.commitsSinceTag(ctx, tag)
	} else {
		repositoryCommits, err = f.commitsSinceInit(ctx)
	}

	if err != nil {
		return nil, err
	}

	var commits = make([]git.Commit, 0, len(repositoryCommits))
	for _, fCommit := range repositoryCommits {
		commit := git.Commit{
			Hash:    fCommit.SHA,
			Message: fCommit.RepoCommit.Message,
		}
		commit.PullRequest, err = f.prForCommit(ctx, commit)
		if err != nil {
			return nil, fmt.Errorf("failed to check for commit pull request: %w", err)
		}

		commits = append(commits, commit)
	}

	return commits, nil
}

func (f *Forgejo) commitsSinceTag(_ context.Context, tag *git.Tag) ([]*forgejo.Commit, error) {
	head := f.options.BaseBranch
	log := f.log.With("base", tag.Hash, "head", head)
	log.Debug("comparing commits")

	compare, _, err := f.client.CompareCommits(
		f.options.Owner, f.options.Repo,
		tag.Hash, head)
	if err != nil {
		return nil, err
	}

	return compare.Commits, nil
}

func (f *Forgejo) commitsSinceInit(_ context.Context) ([]*forgejo.Commit, error) {
	head := f.options.BaseBranch
	log := f.log.With("head", head)
	log.Debug("listing all commits")

	repositoryCommits, err := all(
		func(listOptions forgejo.ListOptions) ([]*forgejo.Commit, *forgejo.Response, error) {
			return f.client.ListRepoCommits(
				f.options.Owner, f.options.Repo,
				forgejo.ListCommitOptions{
					ListOptions: listOptions,
					SHA:         f.options.BaseBranch,
				})
		})
	if err != nil {
		return nil, err
	}

	return repositoryCommits, nil
}

func (f *Forgejo) prForCommit(_ context.Context, commit git.Commit) (*git.PullRequest, error) {
	// We naively look up the associated PR for each commit through the "List pull requests associated with a commit"
	// endpoint. This requires len(commits) requests.
	// Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request,
	// but worst case we need to look up all PRs made in the repository ever.

	f.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash)

	pullRequest, _, err := f.client.GetCommitPullRequest(
		f.options.Owner, f.options.Repo,
		commit.Hash,
	)
	if err != nil {
		if strings.HasPrefix(err.Error(), "pull request does not exist") {
			return nil, nil
		}

		return nil, err
	}

	return forgejoPRToPullRequest(pullRequest), nil
}

func (f *Forgejo) EnsureLabelsExist(_ context.Context, labels []releasepr.Label) error {
	f.log.Debug("fetching labels on repo")
	fLabels, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) {
		return f.client.ListRepoLabels(
			f.options.Owner, f.options.Repo,
			forgejo.ListLabelsOptions{ListOptions: listOptions})
	})
	if err != nil {
		return err
	}

	for _, label := range labels {
		if !slices.ContainsFunc(fLabels, func(fLabel *forgejo.Label) bool {
			return fLabel.Name == label.Name
		}) {
			f.log.Info("creating label in repository", "label.name", label.Name)
			_, _, err = f.client.CreateLabel(
				f.options.Owner, f.options.Repo,
				forgejo.CreateLabelOption{
					Name:        label.Name,
					Color:       label.Color,
					Description: label.Description,
				},
			)
			if err != nil {
				return err
			}
		}
	}

	return nil
}

func (f *Forgejo) PullRequestForBranch(_ context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
	prs, err := all(
		func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) {
			return f.client.ListRepoPullRequests(
				f.options.Owner, f.options.Repo,
				forgejo.ListPullRequestsOptions{
					ListOptions: listOptions,
					State:       forgejo.StateOpen,
				},
			)
		},
	)
	if err != nil {
		return nil, err
	}

	for _, pr := range prs {
		if pr.Base.Ref == f.options.BaseBranch && pr.Head.Ref == branch {
			return forgejoPRToReleasePullRequest(pr), nil
		}
	}

	return nil, nil
}

func (f *Forgejo) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
	fPR, _, err := f.client.CreatePullRequest(
		f.options.Owner, f.options.Repo,
		forgejo.CreatePullRequestOption{
			Title: pr.Title,
			Head:  pr.Head,
			Base:  f.options.BaseBranch,
			Body:  pr.Description,
		},
	)
	if err != nil {
		return err
	}

	// TODO: String ID?
	pr.ID = int(fPR.ID)

	err = f.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels)
	if err != nil {
		return err
	}

	return nil
}

func (f *Forgejo) UpdatePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error {
	_, _, err := f.client.EditPullRequest(
		f.options.Owner, f.options.Repo,
		int64(pr.ID), forgejo.EditPullRequestOption{
			Title: pr.Title,
			Body:  pr.Description,
		},
	)
	if err != nil {
		return err
	}

	return nil
}

func (f *Forgejo) SetPullRequestLabels(_ context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
	allLabels, err := all(
		func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) {
			return f.client.ListRepoLabels(f.options.Owner, f.options.Repo, forgejo.ListLabelsOptions{ListOptions: listOptions})
		},
	)
	if err != nil {
		return err
	}

	findLabel := func(labelName string) *forgejo.Label {
		for _, fLabel := range allLabels {
			if fLabel.Name == labelName {
				return fLabel
			}
		}

		return nil
	}

	for _, label := range remove {
		fLabel := findLabel(label.Name)
		if fLabel == nil {
			return fmt.Errorf("unable to remove label %q, not found in API", label.Name)
		}

		_, err = f.client.DeleteIssueLabel(
			f.options.Owner, f.options.Repo,
			int64(pr.ID), fLabel.ID,
		)
		if err != nil {
			return err
		}
	}

	addIDs := make([]int64, 0, len(add))
	for _, label := range add {
		fLabel := findLabel(label.Name)
		if fLabel == nil {
			return fmt.Errorf("unable to add label %q, not found in API", label.Name)
		}

		addIDs = append(addIDs, fLabel.ID)
	}

	_, _, err = f.client.AddIssueLabels(
		f.options.Owner, f.options.Repo,
		int64(pr.ID), forgejo.IssueLabelsOption{Labels: addIDs},
	)
	if err != nil {
		return err
	}

	return nil
}

func (f *Forgejo) ClosePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error {
	_, _, err := f.client.EditPullRequest(
		f.options.Owner, f.options.Repo,
		int64(pr.ID), forgejo.EditPullRequestOption{
			State: pointer.Pointer(forgejo.StateClosed),
		},
	)
	if err != nil {
		return err
	}

	return nil
}

func (f *Forgejo) PendingReleases(_ context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
	fPRs, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) {
		return f.client.ListRepoPullRequests(
			f.options.Owner, f.options.Repo,
			forgejo.ListPullRequestsOptions{
				// Filtering by Label ID is possible in the API, but not implemented in the Go SDK.
				State:       forgejo.StateClosed,
				ListOptions: listOptions,
			})
	})
	if err != nil {
		// "The target couldn't be found." means that the repo does not have pull requests activated.
		return nil, err
	}

	prs := make([]*releasepr.ReleasePullRequest, 0, len(fPRs))

	for _, pr := range fPRs {
		pending := slices.ContainsFunc(pr.Labels, func(l *forgejo.Label) bool {
			return l.Name == pendingLabel.Name
		})
		if !pending {
			continue
		}

		// pr.Merged is always nil :(
		if !pr.HasMerged {
			// Closed and not merged
			continue
		}

		prs = append(prs, forgejoPRToReleasePullRequest(pr))
	}

	return prs, nil
}

func (f *Forgejo) CreateRelease(_ context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error {
	// latest can not be set through the API

	_, _, err := f.client.CreateRelease(
		f.options.Owner, f.options.Repo,
		forgejo.CreateReleaseOption{
			TagName:      title,
			Target:       commit.Hash,
			Title:        title,
			Note:         changelog,
			IsPrerelease: preRelease,
		},
	)
	if err != nil {
		return err
	}

	return nil
}

func all[T any](f func(listOptions forgejo.ListOptions) ([]T, *forgejo.Response, error)) ([]T, error) {
	results := make([]T, 0)
	page := 1

	for {
		pageResults, resp, err := f(forgejo.ListOptions{Page: page})
		if err != nil {
			return nil, err
		}

		results = append(results, pageResults...)

		if page == resp.LastPage || resp.LastPage == 0 {
			return results, nil
		}
		page = resp.NextPage
	}
}

func forgejoPRToPullRequest(pr *forgejo.PullRequest) *git.PullRequest {
	return &git.PullRequest{
		ID:          int(pr.ID),
		Title:       pr.Title,
		Description: pr.Body,
	}
}

func forgejoPRToReleasePullRequest(pr *forgejo.PullRequest) *releasepr.ReleasePullRequest {
	labels := make([]releasepr.Label, 0, len(pr.Labels))
	for _, label := range pr.Labels {
		labelName := label.Name
		if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool {
			return label.Name == labelName
		}); i >= 0 {
			labels = append(labels, releasepr.KnownLabels[i])
		}
	}

	var releaseCommit *git.Commit
	if pr.MergedCommitID != nil {
		releaseCommit = &git.Commit{Hash: *pr.MergedCommitID}
	}

	return &releasepr.ReleasePullRequest{
		PullRequest: *forgejoPRToPullRequest(pr),
		Labels:      labels,

		Head:          pr.Head.Ref,
		ReleaseCommit: releaseCommit,
	}
}

func (g *Options) autodiscover() {
	// TODO
}

func (g *Options) ClientOptions() []forgejo.ClientOption {
	options := []forgejo.ClientOption{}

	if g.APIToken != "" {
		options = append(options, forgejo.SetToken(g.APIToken))
	}

	return options
}

type Options struct {
	forge.Options

	Owner string
	Repo  string

	APIURL   string
	Username string
	APIToken string
}

func New(log *slog.Logger, options *Options) (*Forgejo, error) {
	options.autodiscover()

	client, err := forgejo.NewClient(options.APIURL, options.ClientOptions()...)
	if err != nil {
		return nil, err
	}

	client.SetUserAgent("releaser-pleaser")

	f := &Forgejo{
		options: options,

		client: client,
		log:    log.With("forge", "forgejo"),
	}

	return f, nil
}
