package giturl

import (
	"net/url"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"

	"github.com/pkg/errors"

	"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)

type provider string

const (
	providerGithub          provider = "Github"
	providerGitlab          provider = "Gitlab"
	providerBitbucket       provider = "Bitbucket"
	providerBitbucketServer provider = "BitbucketServer"
	providerAzure           provider = "Azure"

	urlGithub    = "github.com/"
	urlGitlab    = "gitlab.com/"
	urlBitbucket = "bitbucket.org/"
	urlAzure     = "dev.azure.com/"
)

func determineProvider(repo string) provider {
	switch {
	case strings.Contains(repo, urlGithub):
		return providerGithub
	case strings.Contains(repo, urlGitlab):
		return providerGitlab
	case strings.Contains(repo, urlBitbucket):
		return providerBitbucket
	case strings.Contains(repo, urlAzure):
		return providerAzure
	case isBitbucketServerURL(repo):
		return providerBitbucketServer
	default:
		return ""
	}
}

// isBitbucketServerURL detects Bitbucket Server/Data Center URLs by their
// distinctive /projects/{KEY}/repos/{SLUG}/... path structure.
// See: https://developer.atlassian.com/server/bitbucket/rest/v1002/intro/#about
// See: https://community.atlassian.com/forums/Bitbucket-questions/Bitbucket-Hyperlinking-to-source-code-in-Bitbucket/qaq-p/618967
func isBitbucketServerURL(rawURL string) bool {
	u, err := url.Parse(rawURL)
	if err != nil {
		return false
	}
	return strings.Contains(u.Path, "/projects/") && strings.Contains(u.Path, "/repos/")
}

func NormalizeBitbucketRepo(repoURL string) (string, error) {
	if !strings.HasPrefix(repoURL, "https") {
		return "", errors.New("Bitbucket requires https repo urls: e.g. https://bitbucket.org/org/repo.git")
	}

	return NormalizeOrgRepoURL(providerBitbucket, repoURL)
}

func NormalizeGerritProject(project string) (string, error) {
	return "", errors.Errorf("Not yet implemented")
}

func NormalizeGithubRepo(repoURL string) (string, error) {
	return NormalizeOrgRepoURL(providerGithub, repoURL)
}

func NormalizeGitlabRepo(repoURL string) (string, error) {
	if !strings.HasPrefix(repoURL, "http:") && !strings.HasPrefix(repoURL, "https:") {
		return "", errors.New("Gitlab requires http/https repo urls: e.g. https://gitlab.com/org/repo.git")
	}

	return NormalizeOrgRepoURL(providerGitlab, repoURL)
}

// NormalizeOrgRepoURL attempts to normalize repos for any provider using the example.com/org/repo style.
// e.g. %s, Gitlab and Bitbucket
func NormalizeOrgRepoURL(provider provider, repoURL string) (string, error) {
	if strings.HasSuffix(repoURL, ".git") {
		return repoURL, nil
	}

	parsed, err := url.Parse(repoURL)
	if err != nil {
		return "", errors.Wrapf(err, "Unable to parse %s Repo URL", provider)
	}

	// The provider repo url should have a path length of 3
	//               0 / 1 / 2     (3 total)
	// e.g. example.com/org/repo
	// If it is 1, or the 2nd section is empty, it's likely just the org.
	// If it is 3, there's something at the end that shouldn't be there.
	// Let the user know in any case.
	switch parts := strings.Split(parsed.Path, "/"); {
	case len(parts) <= 1:
		return "", errors.Errorf("%s repo appears to be missing the path. Repo url: %q", provider, repoURL)

	case len(parts) == 2:
		org := parts[1]

		if len(org) == 0 {
			return "", errors.Errorf("%s repo appears to be missing the org name. Repo url: %q", provider, repoURL)
		} else {
			return "", errors.Errorf("%s repo appears to be missing the repo name. Org: %q Repo url: %q", provider, org, repoURL)
		}

	case len(parts) == 3:
		org, repo := parts[1], parts[2]

		if len(org) == 0 {
			return "", errors.Errorf("%s repo appears to be missing the org name. Repo url: %q", provider, repoURL)
		}
		if len(repo) == 0 {
			return "", errors.Errorf("%s repo appears to be missing the repo name. Org: %q Repo url: %q", provider, org, repoURL)
		}

	case len(parts) > 3 && strings.HasSuffix(parsed.Path, "/"):
		return "", errors.Errorf("%s repo contains a trailing slash. Repo url: %q", provider, repoURL)
	}

	// If we're here it's probably a provider repo without ".git" at the end, so add it and return
	parsed.Path += ".git"
	return parsed.String(), nil
}

// GenerateLink crafts a link to the specific file from a commit.
// Supports GitHub, GitLab, Bitbucket, and Azure Repos.
// If the provider supports hyperlinks to specific lines, the line number will be included.
func GenerateLink(repo, commit, file string, line int64) string {
	// Some paths contain '%' which breaks |url.Parse| if not encoded.
	// https://developer.mozilla.org/en-US/docs/Glossary/Percent-encoding
	file = strings.ReplaceAll(file, "%", "%25")
	file = strings.ReplaceAll(file, "[", "%5B")
	file = strings.ReplaceAll(file, "]", "%5D")

	switch determineProvider(repo) {
	case providerBitbucket:
		baseLink := repo[:len(repo)-4] + "/src/" + commit + "/" + file
		if line > 0 {
			baseLink += "#lines-" + strconv.FormatInt(line, 10)
		}
		return baseLink

	case providerAzure:
		// Azure Repos format: ?path=/file&version=GC<commit>&line=N&lineEnd=N+1&lineStartColumn=1
		// where GC prefix stands for "Git Commit" (vs GB for Git Branch, GT for Git Tag)
		baseLink := repo + "?version=GC" + commit
		if file != "" {
			baseLink = repo + "?path=/" + file + "&version=GC" + commit
		}
		if line > 0 {
			lineStr := strconv.FormatInt(line, 10)
			lineEndStr := strconv.FormatInt(line+1, 10)
			baseLink += "&line=" + lineStr + "&lineEnd=" + lineEndStr + "&lineStartColumn=1"
		}
		return baseLink

	case providerGithub, providerGitlab:
		// If the provider name isn't one of the cloud defaults, it is probably an on-prem github or gitlab.
		// So do the same thing.
		fallthrough
	default:
		var baseLink string

		// Gist links are formatted differently
		if strings.HasPrefix(repo, "https://gist.github.com") {
			baseLink = repo[:len(repo)-4] + "/"
			if commit != "" {
				baseLink += commit + "/"
			}
			if file != "" {
				cleanedFileName := strings.ReplaceAll(file, ".", "-")
				baseLink += "#file-" + cleanedFileName
			}
			if line > 0 {
				if strings.Contains(baseLink, "#") {
					baseLink += "-L" + strconv.FormatInt(line, 10)
				} else {
					baseLink += "#L" + strconv.FormatInt(line, 10)
				}
			}
		} else if strings.HasSuffix(repo, ".wiki.git") {
			// GitHub Wiki links are formatted differently
			baseLink = repo[:len(repo)-9] + "/wiki/"
			if file != "" {
				// remove file extension
				baseLink += strings.TrimSuffix(file, filepath.Ext(file)) + "/"
			}
			if commit != "" {
				baseLink += commit
			}
			if line > 0 {
				baseLink += "#L" + strconv.FormatInt(line, 10)
			}
		} else if file == "" {
			baseLink = repo[:len(repo)-4] + "/commit/" + commit
		} else {
			baseLink = repo[:len(repo)-4] + "/blob/" + commit + "/" + file
			if line > 0 {
				baseLink += "#L" + strconv.FormatInt(line, 10)
			}
		}
		return baseLink
	}
}

var (
	linePattern        = regexp.MustCompile(`L\d+`)
	bbCloudLinePattern = regexp.MustCompile(`lines-\d+(:\d+)?`)
)

// UpdateLinkLineNumber updates the line number in a repository link.
// Used post-link generation to refine reported issue locations within large scanned blocks.
func UpdateLinkLineNumber(ctx context.Context, link string, newLine int64) string {
	link = strings.Replace(link, "%", "%25", -1)
	link = strings.Replace(link, "[", "%5B", -1)
	link = strings.Replace(link, "]", "%5D", -1)
	parsedURL, err := url.Parse(link)
	if err != nil {
		ctx.Logger().Error(err, "unable to parse link to update line number", "link", link)
		return link
	}

	if newLine <= 0 {
		// Don't change the link if the line number is 0.
		return link
	}

	switch determineProvider(link) {
	case providerBitbucket:
		// Bitbucket Cloud uses #lines-N format for source file views.
		fragment := "lines-" + strconv.FormatInt(newLine, 10)
		if bbCloudLinePattern.MatchString(parsedURL.Fragment) {
			parsedURL.Fragment = bbCloudLinePattern.ReplaceAllString(parsedURL.Fragment, fragment)
		} else {
			parsedURL.Fragment = fragment
		}

	case providerBitbucketServer:
		// Bitbucket Server/Data Center uses a bare line number as fragment: #N
		parsedURL.Fragment = strconv.FormatInt(newLine, 10)

	case providerAzure:
		// For Azure, line numbers use query parameters: ?line=N&lineEnd=N+1&lineStartColumn=1
		query := parsedURL.Query()
		lineStr := strconv.FormatInt(newLine, 10)
		lineEndStr := strconv.FormatInt(newLine+1, 10)
		query.Set("line", lineStr)
		query.Set("lineEnd", lineEndStr)
		query.Set("lineStartColumn", "1")
		parsedURL.RawQuery = query.Encode()

	case providerGithub, providerGitlab:
		// If the provider name isn't one of the cloud defaults, it is probably an on-prem github or gitlab.
		// So do the same thing.
		fallthrough
	default:
		// Assumed format: .../blob/<commit>/file.go#L<number>
		fragment := "L" + strconv.FormatInt(newLine, 10)
		if linePattern.MatchString(parsedURL.Fragment) {
			parsedURL.Fragment = linePattern.ReplaceAllString(parsedURL.Fragment, fragment)
		} else {
			parsedURL.Fragment += fragment
		}
	}

	return parsedURL.String()
}
