// Copyright 2024 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package bitbucketdatacenter

import (
	"context"
	"fmt"
	"net/http"
	"net/url"
	"strings"

	bb "github.com/neticdk/go-bitbucket/bitbucket"
	"github.com/rs/zerolog/log"
	"golang.org/x/oauth2"

	"go.woodpecker-ci.org/woodpecker/v2/server"
	"go.woodpecker-ci.org/woodpecker/v2/server/forge"
	"go.woodpecker-ci.org/woodpecker/v2/server/forge/bitbucketdatacenter/internal"
	"go.woodpecker-ci.org/woodpecker/v2/server/forge/common"
	forge_types "go.woodpecker-ci.org/woodpecker/v2/server/forge/types"
	"go.woodpecker-ci.org/woodpecker/v2/server/model"
	"go.woodpecker-ci.org/woodpecker/v2/server/store"
)

const listLimit = 250

// Opts defines configuration options.
type Opts struct {
	URL          string // Bitbucket server url for API access.
	Username     string // Git machine account username.
	Password     string // Git machine account password.
	ClientID     string // OAuth 2.0 client id
	ClientSecret string // OAuth 2.0 client secret
}

type client struct {
	url          string
	urlAPI       string
	clientID     string
	clientSecret string
	username     string
	password     string
}

// New returns a Forge implementation that integrates with Bitbucket DataCenter/Server,
// the on-premise edition of Bitbucket Cloud, formerly known as Stash.
func New(opts Opts) (forge.Forge, error) {
	config := &client{
		url:          opts.URL,
		urlAPI:       fmt.Sprintf("%s/rest", opts.URL),
		clientID:     opts.ClientID,
		clientSecret: opts.ClientSecret,
		username:     opts.Username,
		password:     opts.Password,
	}

	switch {
	case opts.Username == "":
		return nil, fmt.Errorf("must have a git machine account username")
	case opts.Password == "":
		return nil, fmt.Errorf("must have a git machine account password")
	case opts.ClientID == "":
		return nil, fmt.Errorf("must have an oauth 2.0 client id")
	case opts.ClientSecret == "":
		return nil, fmt.Errorf("must have an oauth 2.0 client secret")
	}

	return config, nil
}

// Name returns the string name of this driver
func (c *client) Name() string {
	return "bitbucket_dc"
}

// URL returns the root url of a configured forge
func (c *client) URL() string {
	return c.url
}

func (c *client) Login(ctx context.Context, req *forge_types.OAuthRequest) (*model.User, string, error) {
	config := c.newOAuth2Config()

	// TODO: Add proper state and pkce...
	redirectURL := config.AuthCodeURL("woodpecker")

	if req.Error != "" {
		return nil, redirectURL, &forge_types.AuthError{
			Err:         req.Error,
			Description: req.ErrorDescription,
			URI:         req.ErrorURI,
		}
	}

	if len(req.Code) == 0 {
		return nil, redirectURL, nil
	}

	token, err := config.Exchange(ctx, req.Code)
	if err != nil {
		return nil, redirectURL, err
	}

	client := internal.NewClientWithToken(ctx, config.TokenSource(ctx, &oauth2.Token{
		AccessToken: token.AccessToken,
	}), c.url)
	userSlug, err := client.FindCurrentUser(ctx)
	if err != nil {
		return nil, "", err
	}

	bc, err := c.newClient(ctx, &model.User{Token: token.AccessToken})
	if err != nil {
		return nil, "", fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	user, _, err := bc.Users.GetUser(ctx, userSlug)
	if err != nil {
		return nil, "", fmt.Errorf("unable to query for user: %w", err)
	}

	u := convertUser(user, c.url)
	updateUserCredentials(u, token)
	return u, "", nil
}

func (c *client) Auth(ctx context.Context, accessToken, _ string) (string, error) {
	config := c.newOAuth2Config()
	token := &oauth2.Token{
		AccessToken: accessToken,
	}
	client := internal.NewClientWithToken(ctx, config.TokenSource(ctx, token), c.url)
	return client.FindCurrentUser(ctx)
}

func (c *client) Refresh(ctx context.Context, u *model.User) (bool, error) {
	config := c.newOAuth2Config()
	t := &oauth2.Token{
		RefreshToken: u.Secret,
	}
	ts := config.TokenSource(ctx, t)

	tok, err := ts.Token()
	if err != nil {
		return false, fmt.Errorf("unable to refresh OAuth 2.0 token from bitbucket datacenter: %w", err)
	}
	updateUserCredentials(u, tok)
	return true, nil
}

func (c *client) Repo(ctx context.Context, u *model.User, rID model.ForgeRemoteID, owner, name string) (*model.Repo, error) {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	var repo *bb.Repository
	if rID.IsValid() {
		opts := &bb.RepositorySearchOptions{Permission: bb.PermissionRepoWrite, ListOptions: bb.ListOptions{Limit: listLimit}}
		for {
			repos, resp, err := bc.Projects.SearchRepositories(ctx, opts)
			if err != nil {
				return nil, fmt.Errorf("unable to search repositories: %w", err)
			}
			for _, r := range repos {
				if rID == convertID(r.ID) {
					repo = r
					break
				}
			}
			if resp.LastPage {
				break
			}
			opts.Start = resp.NextPageStart
		}
		if repo == nil {
			return nil, fmt.Errorf("unable to find repository with id: %s", rID)
		}
	} else {
		repo, _, err = bc.Projects.GetRepository(ctx, owner, name)
		if err != nil {
			return nil, fmt.Errorf("unable to get repository: %w", err)
		}
	}

	b, _, err := bc.Projects.GetDefaultBranch(ctx, repo.Project.Key, repo.Slug)
	if err != nil {
		return nil, fmt.Errorf("unable to fetch default branch: %w", err)
	}

	perms := &model.Perm{Pull: true, Push: true}
	_, _, err = bc.Projects.ListWebhooks(ctx, repo.Project.Key, repo.Slug, &bb.ListOptions{})
	if err == nil {
		perms.Admin = true
	}

	return convertRepo(repo, perms, b.DisplayID), nil
}

func (c *client) Repos(ctx context.Context, u *model.User) ([]*model.Repo, error) {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	opts := &bb.RepositorySearchOptions{Permission: bb.PermissionRepoWrite, ListOptions: bb.ListOptions{Limit: listLimit}}
	var all []*model.Repo
	for {
		repos, resp, err := bc.Projects.SearchRepositories(ctx, opts)
		if err != nil {
			return nil, fmt.Errorf("unable to search repositories: %w", err)
		}
		for _, r := range repos {
			perms := &model.Perm{Pull: true, Push: true, Admin: false}
			all = append(all, convertRepo(r, perms, ""))
		}
		if resp.LastPage {
			break
		}
		opts.Start = resp.NextPageStart
	}

	// Add admin permissions to relevant repositories
	opts = &bb.RepositorySearchOptions{Permission: bb.PermissionRepoAdmin, ListOptions: bb.ListOptions{Limit: listLimit}}
	for {
		repos, resp, err := bc.Projects.SearchRepositories(ctx, opts)
		if err != nil {
			return nil, fmt.Errorf("unable to search repositories: %w", err)
		}
		for _, r := range repos {
			for i, c := range all {
				if c.ForgeRemoteID == convertID(r.ID) {
					all[i].Perm = &model.Perm{Pull: true, Push: true, Admin: true}
					break
				}
			}
		}
		if resp.LastPage {
			break
		}
		opts.Start = resp.NextPageStart
	}

	return all, nil
}

func (c *client) File(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, f string) ([]byte, error) {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	b, _, err := bc.Projects.GetTextFileContent(ctx, r.Owner, r.Name, f, p.Commit)
	if err != nil {
		return nil, err
	}
	return b, nil
}

func (c *client) Dir(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline, path string) ([]*forge_types.FileMeta, error) {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	opts := &bb.FilesListOptions{At: p.Commit}
	var all []*forge_types.FileMeta
	for {
		list, resp, err := bc.Projects.ListFiles(ctx, r.Owner, r.Name, path, opts)
		if err != nil {
			if resp.StatusCode == http.StatusNotFound {
				break // requested directory might not exist
			}
			return nil, err
		}
		for _, f := range list {
			fullpath := fmt.Sprintf("%s/%s", path, f)
			data, err := c.File(ctx, u, r, p, fullpath)
			if err != nil {
				return nil, err
			}
			all = append(all, &forge_types.FileMeta{Name: fullpath, Data: data})
		}
		if resp.LastPage {
			break
		}
		opts.Start = resp.NextPageStart
	}
	return all, nil
}

func (c *client) Status(ctx context.Context, u *model.User, repo *model.Repo, pipeline *model.Pipeline, workflow *model.Workflow) error {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return fmt.Errorf("unable to create bitbucket client: %w", err)
	}
	status := &bb.BuildStatus{
		State:       convertStatus(pipeline.Status),
		URL:         common.GetPipelineStatusURL(repo, pipeline, workflow),
		Key:         common.GetPipelineStatusContext(repo, pipeline, workflow),
		Description: common.GetPipelineStatusDescription(pipeline.Status),
	}
	_, err = bc.Projects.CreateBuildStatus(ctx, repo.Owner, repo.Name, pipeline.Commit, status)
	return err
}

func (c *client) Netrc(_ *model.User, r *model.Repo) (*model.Netrc, error) {
	host, err := common.ExtractHostFromCloneURL(r.Clone)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	return &model.Netrc{
		Login:    c.username,
		Password: c.password,
		Machine:  host,
	}, nil
}

// Branches returns the names of all branches for the named repository.
func (c *client) Branches(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]string, error) {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	opts := &bb.BranchSearchOptions{ListOptions: convertListOptions(p)}
	var all []string
	for {
		branches, resp, err := bc.Projects.SearchBranches(ctx, r.Owner, r.Name, opts)
		if err != nil {
			return nil, fmt.Errorf("unable to list branches: %w", err)
		}
		for _, b := range branches {
			all = append(all, b.DisplayID)
		}
		if !p.All || resp.LastPage {
			break
		}
		opts.Start = resp.NextPageStart
	}

	return all, nil
}

func (c *client) BranchHead(ctx context.Context, u *model.User, r *model.Repo, b string) (*model.Commit, error) {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}
	branches, _, err := bc.Projects.SearchBranches(ctx, r.Owner, r.Name, &bb.BranchSearchOptions{Filter: b})
	if err != nil {
		return nil, err
	}
	if len(branches) == 0 {
		return nil, fmt.Errorf("no matching branches returned")
	}
	for _, branch := range branches {
		if branch.DisplayID == b {
			return &model.Commit{
				SHA:      branch.LatestCommit,
				ForgeURL: fmt.Sprintf("%s/commits/%s", r.ForgeURL, branch.LatestCommit),
			}, nil
		}
	}
	return nil, fmt.Errorf("no matching branches found")
}

func (c *client) PullRequests(ctx context.Context, u *model.User, r *model.Repo, p *model.ListOptions) ([]*model.PullRequest, error) {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	opts := &bb.PullRequestSearchOptions{ListOptions: convertListOptions(p)}
	var all []*model.PullRequest
	for {
		prs, resp, err := bc.Projects.SearchPullRequests(ctx, r.Owner, r.Name, opts)
		if err != nil {
			return nil, fmt.Errorf("unable to list pull-requests: %w", err)
		}
		for _, pr := range prs {
			all = append(all, &model.PullRequest{Index: convertID(pr.ID), Title: pr.Title})
		}
		if !p.All || resp.LastPage {
			break
		}
		opts.Start = resp.NextPageStart
	}

	return all, nil
}

func (c *client) Activate(ctx context.Context, u *model.User, r *model.Repo, link string) error {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	err = c.Deactivate(ctx, u, r, link)
	if err != nil {
		return fmt.Errorf("unable to deactive old webhooks: %w", err)
	}

	webhook := &bb.Webhook{
		Name:   "Woodpecker",
		URL:    link,
		Events: []bb.EventKey{bb.EventKeyRepoRefsChanged, bb.EventKeyPullRequestFrom, bb.EventKeyPullRequestMerged, bb.EventKeyPullRequestDeclined, bb.EventKeyPullRequestDeleted},
		Active: true,
		Config: &bb.WebhookConfiguration{
			Secret: r.Hash,
		},
	}
	_, _, err = bc.Projects.CreateWebhook(ctx, r.Owner, r.Name, webhook)
	return err
}

func (c *client) Deactivate(ctx context.Context, u *model.User, r *model.Repo, link string) error {
	bc, err := c.newClient(ctx, u)
	if err != nil {
		return fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	lu, err := url.Parse(link)
	if err != nil {
		return err
	}

	opts := &bb.ListOptions{}
	var ids []uint64
	for {
		hooks, resp, err := bc.Projects.ListWebhooks(ctx, r.Owner, r.Name, opts)
		if err != nil {
			return err
		}
		for _, h := range hooks {
			hu, err := url.Parse(h.URL)
			if err == nil && hu.Host == lu.Host {
				ids = append(ids, h.ID)
			}
		}
		if resp.LastPage {
			break
		}
		opts.Start = resp.NextPageStart
	}

	for _, id := range ids {
		_, err = bc.Projects.DeleteWebhook(ctx, r.Owner, r.Name, id)
		if err != nil {
			return err
		}
	}

	return nil
}

func (c *client) Hook(ctx context.Context, r *http.Request) (*model.Repo, *model.Pipeline, error) {
	ev, payload, err := bb.ParsePayloadWithoutSignature(r)
	if err != nil {
		return nil, nil, fmt.Errorf("unable to parse payload from webhook invocation: %w", err)
	}

	var repo *model.Repo
	var pipe *model.Pipeline
	switch e := ev.(type) {
	case *bb.RepositoryPushEvent:
		repo = convertRepo(&e.Repository, nil, "")
		pipe = convertRepositoryPushEvent(e, c.url)
	case *bb.PullRequestEvent:
		repo = convertRepo(&e.PullRequest.Source.Repository, nil, "")
		pipe = convertPullRequestEvent(e, c.url)
	default:
		return nil, nil, nil
	}

	user, repo, err := c.getUserAndRepo(ctx, repo)
	if err != nil {
		return nil, nil, err
	}

	err = bb.ValidateSignature(r, payload, []byte(repo.Hash))
	if err != nil {
		return nil, nil, fmt.Errorf("unable to validate signature on incoming webhook payload: %w", err)
	}

	pipe, err = c.updatePipelineFromCommit(ctx, user, repo, pipe)
	if err != nil {
		return nil, nil, err
	}

	if pipe == nil {
		return nil, nil, nil
	}

	return repo, pipe, nil
}

func (c *client) getUserAndRepo(ctx context.Context, r *model.Repo) (*model.User, *model.Repo, error) {
	_store, ok := store.TryFromContext(ctx)
	if !ok {
		log.Error().Msg("could not get store from context")
		return nil, nil, fmt.Errorf("unable to get store from context")
	}

	repo, err := _store.GetRepoForgeID(r.ForgeRemoteID)
	if err != nil {
		return nil, nil, fmt.Errorf("unable to get repo: %w", err)
	}
	log.Trace().Any("repo", repo).Msg("got repo")

	user, err := _store.GetUser(repo.UserID)
	if err != nil {
		return nil, nil, fmt.Errorf("unable to get user: %w", err)
	}
	log.Trace().Any("user", user).Msg("got user")

	forge.Refresh(ctx, c, _store, user)

	return user, repo, nil
}

func (c *client) updatePipelineFromCommit(ctx context.Context, u *model.User, r *model.Repo, p *model.Pipeline) (*model.Pipeline, error) {
	if p == nil {
		return nil, nil
	}

	bc, err := c.newClient(ctx, u)
	if err != nil {
		return nil, fmt.Errorf("unable to create bitbucket client: %w", err)
	}

	commit, _, err := bc.Projects.GetCommit(ctx, r.Owner, r.Name, p.Commit)
	if err != nil {
		return nil, fmt.Errorf("unable to read commit: %w", err)
	}
	p.Message = commit.Message

	opts := &bb.ListOptions{}
	for {
		changes, resp, err := bc.Projects.ListChanges(ctx, r.Owner, r.Name, p.Commit, opts)
		if err != nil {
			return nil, fmt.Errorf("unable to list commit changes: %w", err)
		}
		for _, ch := range changes {
			p.ChangedFiles = append(p.ChangedFiles, ch.Path.Title)
		}
		if resp.LastPage {
			break
		}
		opts.Start = resp.NextPageStart
	}

	return p, nil
}

// Teams is not supported.
func (*client) Teams(_ context.Context, _ *model.User) ([]*model.Team, error) {
	var teams []*model.Team
	return teams, nil
}

// TeamPerm is not supported.
func (*client) TeamPerm(_ *model.User, _ string) (*model.Perm, error) {
	return nil, nil
}

// OrgMembership returns if user is member of organization and if user
// is admin/owner in this organization.
func (c *client) OrgMembership(_ context.Context, _ *model.User, _ string) (*model.OrgPerm, error) {
	// TODO: Not implemented currently
	return nil, nil
}

// Org fetches the organization from the forge by name. If the name is a user an org with type user is returned.
func (c *client) Org(_ context.Context, _ *model.User, owner string) (*model.Org, error) {
	if strings.HasPrefix(owner, "~") {
		return &model.Org{
			Name:   owner,
			IsUser: true,
		}, nil
	}
	return &model.Org{
		Name:   owner,
		IsUser: false,
	}, nil
}

func (c *client) newOAuth2Config() *oauth2.Config {
	return &oauth2.Config{
		ClientID:     c.clientID,
		ClientSecret: c.clientSecret,
		Endpoint: oauth2.Endpoint{
			AuthURL:  fmt.Sprintf("%s/oauth2/latest/authorize", c.urlAPI),
			TokenURL: fmt.Sprintf("%s/oauth2/latest/token", c.urlAPI),
		},
		Scopes:      []string{string(bb.PermissionRepoRead), string(bb.PermissionRepoWrite), string(bb.PermissionRepoAdmin)},
		RedirectURL: fmt.Sprintf("%s/authorize", server.Config.Server.OAuthHost),
	}
}

func (c *client) newClient(ctx context.Context, u *model.User) (*bb.Client, error) {
	config := c.newOAuth2Config()
	t := &oauth2.Token{
		AccessToken: u.Token,
	}
	client := config.Client(ctx, t)
	return bb.NewClient(c.urlAPI, client)
}
