package github

import (
	"fmt"
	"strings"
	"time"

	"github.com/fatih/color"
	gh "github.com/google/go-github/v67/github"
	"golang.org/x/time/rate"

	"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
	"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github/classic"
	"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github/common"
	"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github/finegrained"
	"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
	"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)

// According to GitHub's rate limiting documentation, the default rate limit for
// authenticated requests (PAT) is 5000 requests per hour. This equates to roughly 1.39
// requests per second. To provide some buffer, we set the rate limit to 1.25
// requests per second with a burst of 10.
// https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28
var rateLimiter = rate.NewLimiter(rate.Limit(1.25), 10)

var _ analyzers.Analyzer = (*Analyzer)(nil)

type Analyzer struct {
	Cfg *config.Config
}

func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeGitHub }

func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
	info, err := AnalyzePermissions(a.Cfg, credInfo["key"])
	if err != nil {
		return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", err)
	}
	if info == nil {
		return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", fmt.Errorf("GitHub analyzer returned no data for token"))
	}
	return secretInfoToAnalyzerResult(info), nil
}

func secretInfoToAnalyzerResult(info *common.SecretInfo) *analyzers.AnalyzerResult {
	if info == nil {
		return nil
	}
	result := &analyzers.AnalyzerResult{
		AnalyzerType: analyzers.AnalyzerTypeGitHub,
		Metadata: map[string]any{
			"owner":      info.Metadata.User.Login,
			"type":       info.Metadata.Type,
			"expiration": info.Metadata.Expiration,
		},
	}
	result.Bindings = append(result.Bindings, secretInfoToUserBindings(info)...)
	result.Bindings = append(result.Bindings, secretInfoToRepoBindings(info)...)
	result.Bindings = append(result.Bindings, secretInfoToGistBindings(info)...)
	for _, repo := range append(info.Repos, info.AccessibleRepos...) {
		if repo.Owner.GetType() != "Organization" {
			continue
		}
		name := repo.Owner.GetName()
		if name == "" {
			continue
		}
		result.UnboundedResources = append(result.UnboundedResources, analyzers.Resource{
			Name:               name,
			FullyQualifiedName: fmt.Sprintf("github.com/%s", name),
			Type:               "organization",
		})
	}
	// TODO: Unbound resources
	// - Repo owners
	// - Gist owners
	return result
}

func secretInfoToUserBindings(info *common.SecretInfo) []analyzers.Binding {
	return analyzers.BindAllPermissions(*userToResource(info.Metadata.User), info.Metadata.OauthScopes...)
}

func userToResource(user *gh.User) *analyzers.Resource {
	name := user.GetLogin()
	return &analyzers.Resource{
		Name:               name,
		FullyQualifiedName: fmt.Sprintf("github.com/%s", name),
		Type:               strings.ToLower(user.GetType()), // "user" or "organization"
	}
}

func secretInfoToRepoBindings(info *common.SecretInfo) []analyzers.Binding {
	var perms []analyzers.Permission
	switch info.Metadata.Type {
	case common.TokenTypeClassicPAT:
		perms = info.Metadata.OauthScopes
	case common.TokenTypeFineGrainedPAT:
		fineGrainedPermissions := info.RepoAccessMap.([]finegrained.Permission)
		for _, perm := range fineGrainedPermissions {
			permName, _ := perm.ToString()
			perms = append(perms, analyzers.Permission{Value: permName})
		}
	default:
		if len(info.Metadata.OauthScopes) > 0 {
			perms = info.Metadata.OauthScopes
		}
	}

	repos := info.Repos
	if len(info.AccessibleRepos) > 0 {
		repos = info.AccessibleRepos
	}
	var bindings []analyzers.Binding
	for _, repo := range repos {
		// A repo has identity independent of its owner (name/full_name); if the
		// owner is absent we still emit the repo but leave Parent unset.
		var parent *analyzers.Resource
		if owner := repo.GetOwner(); owner != nil {
			parent = userToResource(owner)
		}
		resource := analyzers.Resource{
			Name:               repo.GetName(),
			FullyQualifiedName: fmt.Sprintf("github.com/%s", repo.GetFullName()),
			Type:               "repository",
			Parent:             parent,
		}
		bindings = append(bindings, analyzers.BindAllPermissions(resource, perms...)...)
	}
	return bindings
}

func secretInfoToGistBindings(info *common.SecretInfo) []analyzers.Binding {
	var bindings []analyzers.Binding
	for _, gist := range info.Gists {
		// A gist without an owner cannot be attributed to a user, so we cannot
		// build a meaningful resource or FQN for it. Skip it rather than
		// producing silently corrupt output (e.g. "gist.github.com//id").
		if gist.GetOwner() == nil {
			continue
		}
		resource := analyzers.Resource{
			Name:               gist.GetDescription(),
			FullyQualifiedName: fmt.Sprintf("gist.github.com/%s/%s", gist.GetOwner().GetLogin(), gist.GetID()),
			Type:               "gist",
			Parent:             userToResource(gist.GetOwner()),
		}
		bindings = append(bindings, analyzers.BindAllPermissions(resource, info.Metadata.OauthScopes...)...)
	}
	return bindings
}

func AnalyzePermissions(cfg *config.Config, key string) (*common.SecretInfo, error) {
	if cfg == nil {
		cfg = &config.Config{}
	}
	client := gh.NewClient(analyzers.NewAnalyzeClient(cfg, analyzers.WithRateLimiter(rateLimiter))).WithAuthToken(key)

	md, err := common.GetTokenMetadata(key, client)
	if err != nil {
		return nil, err
	}

	if md.FineGrained {
		return finegrained.AnalyzeFineGrainedToken(client, md, cfg.Shallow)
	} else {
		return classic.AnalyzeClassicToken(client, md)
	}
}

func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
	info, err := AnalyzePermissions(cfg, key)
	if err != nil {
		color.Red("[x] %s", err.Error())
		return
	}

	color.Yellow("[i] Token User: %v", *info.Metadata.User.Login)
	if expiry := info.Metadata.Expiration; expiry.IsZero() {
		color.Red("[i] Token Expiration: does not expire")
	} else {
		timeRemaining := time.Until(expiry)
		color.Yellow("[i] Token Expiration: %v (%s remaining)", expiry, roughHumanReadableDuration(timeRemaining))
	}
	color.Yellow("[i] Token Type: %s\n\n", info.Metadata.Type)

	if info.Metadata.FineGrained {
		finegrained.PrintFineGrainedToken(cfg, info)
		return
	}
	classic.PrintClassicToken(cfg, info)
}

// roughHumanReadableDuration converts a duration into a rough estimate for
// human consumption. The larger the duration, the larger granularity is
// returned.
func roughHumanReadableDuration(d time.Duration) string {
	var gran time.Duration
	var unit string
	switch {
	case d < 1*time.Minute:
		gran = time.Second
		unit = "second"
	case d < 1*time.Hour:
		gran = time.Minute
		unit = "minute"
	case d < 24*time.Hour:
		gran = time.Hour
		unit = "hour"
	case d < 4*7*24*time.Hour:
		gran = 24 * time.Hour
		unit = "day"
	case d < 3*4*7*24*time.Hour:
		gran = 7 * 24 * time.Hour
		unit = "week"
	case d < 5*365*24*time.Hour:
		gran = 365 * 24 * time.Hour
		unit = "month"
	default:
		gran = 365 * 24 * time.Hour
		unit = "year"
	}
	num := d.Round(gran) / gran
	if num != 1 {
		unit += "s"
	}
	return fmt.Sprintf("%d %s", num, unit)
}
