// Copyright 2020-2025 Buf Technologies, Inc.
//
// 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 git

import (
	"bufio"
	"bytes"
	"context"
	"errors"
	"fmt"
	"net/url"
	"os/exec"
	"regexp"
	"strings"

	"github.com/bufbuild/buf/private/pkg/app"
	"github.com/bufbuild/buf/private/pkg/execext"
)

const (
	bitBucketHostname        = "bitbucket"
	githubHostname           = "github"
	gitlabHostname           = "gitlab"
	gitSuffix                = ".git"
	githubRemoteURLFormat    = "https://%s%s/commit/%s"
	gitlabRemoteURLFormat    = "https://%s%s/commit/%s"
	bitBucketRemoteURLFormat = "https://%s%s/commits/%s"

	// remoteKindUnknown is a remote to a unknown Git source.
	remoteKindUnknown remoteKind = iota + 1
	// RemoteKindGitHub is a remote to a GitHub Git source.
	remoteKindGitHub
	// RemoteKindGitLab is a remote to a GitLab Git source.
	remoteKindGitLab
	// RemoteKindBitBucket is a remote to a BitBucket Git source.
	remoteKindBitBucket
)

// remoteKind is the kind of remote based on Git source (e.g. GitHub, GitLab, BitBucket, etc.)
type remoteKind int

type remote struct {
	name           string
	kind           remoteKind
	hostname       string
	repositoryPath string
	headBranch     string
}

func (r *remote) Name() string {
	return r.name
}

func (r *remote) Hostname() string {
	return r.hostname
}

func (r *remote) RepositoryPath() string {
	return r.repositoryPath
}

func (r *remote) HEADBranch() string {
	return r.headBranch
}

func (r *remote) isRemote() {}

func (r *remote) SourceControlURL(gitCommitSha string) string {
	switch r.kind {
	case remoteKindBitBucket:
		return fmt.Sprintf(
			bitBucketRemoteURLFormat,
			r.Hostname(),
			r.RepositoryPath(),
			gitCommitSha,
		)
	case remoteKindGitHub:
		return fmt.Sprintf(
			githubRemoteURLFormat,
			r.Hostname(),
			r.RepositoryPath(),
			gitCommitSha,
		)
	case remoteKindGitLab:
		return fmt.Sprintf(
			gitlabRemoteURLFormat,
			r.Hostname(),
			r.RepositoryPath(),
			gitCommitSha,
		)
	}
	// Unknown remote kind, we return an empty URL.
	return ""
}

func newRemote(
	name string,
	kind remoteKind,
	hostname string,
	repositoryPath string,
	headBranch string,
) *remote {
	return &remote{
		name:           name,
		kind:           kind,
		hostname:       hostname,
		repositoryPath: repositoryPath,
		headBranch:     headBranch,
	}
}

func getRemote(
	ctx context.Context,
	envContainer app.EnvContainer,
	dir string,
	name string,
) (*remote, error) {
	if err := validateRemoteExists(ctx, envContainer, dir, name); err != nil {
		return nil, err
	}
	hostname, repositoryPath, err := getRemoteURLMetadata(
		ctx,
		envContainer,
		dir,
		name,
	)
	if err != nil {
		return nil, fmt.Errorf("failed to get remote URL metadata: %w", err)
	}
	headBranch, err := getRemoteHEADBranch(ctx, envContainer, dir, name)
	if err != nil {
		return nil, fmt.Errorf("failed to get remote HEAD branch: %w", err)
	}
	return newRemote(
		name,
		getRemoteKindFromHostname(hostname),
		hostname,
		repositoryPath,
		headBranch,
	), nil
}

func validateRemoteExists(
	ctx context.Context,
	envContainer app.EnvContainer,
	dir string,
	name string,
) error {
	stdout := bytes.NewBuffer(nil)
	stderr := bytes.NewBuffer(nil)
	if err := execext.Run(
		ctx,
		gitCommand,
		execext.WithArgs("ls-remote", "--exit-code", name),
		execext.WithStdout(stdout),
		execext.WithStderr(stderr),
		execext.WithDir(dir),
		execext.WithEnv(app.Environ(envContainer)),
	); err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) {
			if exitErr.ProcessState.ExitCode() == 128 {
				return fmt.Errorf("remote %s: %w", name, ErrRemoteNotFound)
			}
		}
		return err
	}
	return nil
}

func getRemoteURLMetadata(
	ctx context.Context,
	envContainer app.EnvContainer,
	dir string,
	remote string,
) (string, string, error) {
	stdout := bytes.NewBuffer(nil)
	stderr := bytes.NewBuffer(nil)
	if err := execext.Run(
		ctx,
		gitCommand,
		// We use `git config --get remote.<remote>.url` instead of `git remote get-url
		// since it is more specific to the checkout.
		execext.WithArgs("config", "--get", fmt.Sprintf("remote.%s.url", remote)),
		execext.WithStdout(stdout),
		execext.WithStderr(stderr),
		execext.WithDir(dir),
		execext.WithEnv(app.Environ(envContainer)),
	); err != nil {
		return "", "", err
	}
	hostname, repositoryPath := parseRawRemoteURL(strings.TrimSpace(stdout.String()))
	return hostname, repositoryPath, nil
}

func parseRawRemoteURL(rawURL string) (string, string) {
	parsed, err := url.Parse(rawURL)
	if err != nil {
		// Attempt to check if this is a scp-style URL.
		parsed = parseSCPLikeURL(rawURL)
	}
	// If we are unable to parse host and path information from the URL, then we return
	// no information.
	if parsed == nil {
		return "", ""
	}
	return parsed.Hostname(), strings.TrimSuffix(parsed.Path, gitSuffix)
}

func parseSCPLikeURL(rawURL string) *url.URL {
	// https://git-scm.com/docs/git-clone#_git_urls
	// An alternative scp-like syntax may also be used with the ssh protocol:
	//   [<user>@]<host>:/<path-to-git-repo>
	//
	// To parse this, we use a regexp from cmd/go/internal/vcs/vcs.go
	// https://cs.opensource.google/go/go/+/refs/tags/go1.21.10:src/cmd/go/internal/vcs/vcs.go;l=281-283
	scpSyntaxRegexp := regexp.MustCompile(`^(\w+)@([\w.-]+):(.*)$`)
	if match := scpSyntaxRegexp.FindStringSubmatch(rawURL); match != nil {
		// The host and path are the only relevant URL components to our use case.
		return &url.URL{
			Host: match[2],
			Path: "/" + match[3], // scp-like URLs do not have the leading slash
		}
	}
	return nil
}

// getRemoteHEADBranch returns the HEAD branch based on the given remote and given
// directory. Querying the remote for the HEAD branch requires passing the
// environment for permissions.
func getRemoteHEADBranch(
	ctx context.Context,
	envContainer app.EnvContainer,
	dir string,
	remote string,
) (string, error) {
	stdout := bytes.NewBuffer(nil)
	stderr := bytes.NewBuffer(nil)
	if err := execext.Run(
		ctx,
		gitCommand,
		execext.WithArgs("remote", "show", remote),
		execext.WithStdout(stdout),
		execext.WithStderr(stderr),
		execext.WithDir(dir),
		execext.WithEnv(app.Environ(envContainer)),
	); err != nil {
		return "", err
	}
	scanner := bufio.NewScanner(stdout)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if branch, isHEADBranch := strings.CutPrefix(line, "HEAD branch:"); isHEADBranch {
			return strings.TrimSpace(branch), nil
		}
	}
	return "", errors.New("no HEAD branch information found")
}

func getRemoteKindFromHostname(hostname string) remoteKind {
	if strings.Contains(hostname, bitBucketHostname) {
		return remoteKindBitBucket
	}
	if strings.Contains(hostname, githubHostname) {
		return remoteKindGitHub
	}
	if strings.Contains(hostname, gitlabHostname) {
		return remoteKindGitLab
	}
	return remoteKindUnknown
}
