package main

import (
	"context"
	"fmt"
	"github.com/google/go-github/github"
	"golang.org/x/oauth2"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"os/signal"
	"path"
	"strings"
)

// Owner blah blah
type Owner struct {
	name        string
	url         string
	accountType string
	path        string
	reportPath  string
	repos       []Repo
}

// ownerPath is used by newOwner and is responsible for returning a path parsed from
// opts.ClonePath, PWD, or a temporary directory. If a user provides --clone-path=$Home/Desktop/audits
// then the owner path with be $HOME/Desktop/audits. If the user does not provide a --clone-path= argument
// then ownerPath will return the current working directory. If the user sets the temporary option, then
// ownerPath will be $TMPDIR/ownerName. For example running gitleaks on github.com/mozilla, ownerPath would
// return $TMPDIR/mozilla
func ownerPath(ownerName string) (string, error) {
	if opts.Tmp {
		dir, err := ioutil.TempDir("", ownerName)
		return dir, err
	} else if opts.ClonePath != "" {
		if _, err := os.Stat(opts.ClonePath); os.IsNotExist(err) {
			os.Mkdir(opts.ClonePath, os.ModePerm)
		}
		return opts.ClonePath, nil
	} else {
		return os.Getwd()
	}
}

// newOwner is the entry point for gitleaks after all the options have been parsed and
// is responsible for returning an Owner pointer. If running in localmode then the Owner
// that gets created will create a single repo specified in opts.RepoPath. Otherwise
// newOwner will go out to github and fetch all the repos associated with the owner if
// gitleaks is running in owner mode. If gitleaks is running in a non-local repo mode, then
// newOwner will skip hitting the github api and go directly to cloning.
func newOwner() *Owner {
	name := ownerName()
	ownerPath, err := ownerPath(name)
	if err != nil {
		failF("%v", err)
	}
	owner := &Owner{
		name:        name,
		url:         opts.URL,
		accountType: ownerType(),
		path:        ownerPath,
	}

	// listen for ctrl-c
	// NOTE: need some help on how to actually shut down gracefully.
	// On interrupt a repo may still be trying to clone... This has no
	// actual effect other than extraneous logging.
	sigC := make(chan os.Signal, 1)
	signal.Notify(sigC, os.Interrupt, os.Interrupt)
	go func() {
		<-sigC
		if opts.Tmp {
			owner.rmTmp()
		}
		os.Exit(ExitFailure)
	}()

	// if running on local repo, just go right to it.
	if opts.LocalMode {
		repo := newLocalRepo(opts.RepoPath)
		owner.repos = append(owner.repos, *repo)
		return owner
	}

	err = owner.fetchRepos()
	if err != nil {
		owner.failF("%v", err)
	}
	return owner
}

// fetchRepos is used by newOwner and is responsible for fetching one or more
// of the owner's repos. If opts.RepoURL is not the empty string then fetchRepos will
// only grab the repo specified in opts.RepoURL. Otherwise, fetchRepos will reach out to
// github's api and grab all repos associated with owner.
func (owner *Owner) fetchRepos() error {
	var err error
	ctx := context.Background()
	if owner.accountType == "" {
		// single repo, ambiguous account type
		_, repoName := path.Split(opts.URL)
		repo := newRepo(repoName, opts.URL, owner.path+"/"+repoName)
		owner.repos = append(owner.repos, *repo)
	} else {
		// org or user account type, would fail if not valid before
		tokenClient := githubTokenClient()
		gitClient := github.NewClient(tokenClient)

		if owner.accountType == "org" {
			// org account type
			orgOpt := &github.RepositoryListByOrgOptions{
				ListOptions: github.ListOptions{PerPage: 10},
			}
			err = owner.fetchOrgRepos(ctx, orgOpt, gitClient)
		} else {
			// user account type
			userOpt := &github.RepositoryListOptions{
				ListOptions: github.ListOptions{PerPage: 10},
			}
			err = owner.fetchUserRepos(ctx, userOpt, gitClient)
		}
	}
	return err
}

// fetchOrgRepos used by fetchRepos is responsible for parsing github's org repo response. If no
// github token is available then fetchOrgRepos might run into a rate limit in which case owner will
// log an error and gitleaks will exit. The rate limit for no token is 50 req/hour... not much.
func (owner *Owner) fetchOrgRepos(ctx context.Context, orgOpts *github.RepositoryListByOrgOptions,
	gitClient *github.Client) error {
	var (
		githubRepos []*github.Repository
		resp        *github.Response
		err         error
	)

	for {
		githubRepos, resp, err = gitClient.Repositories.ListByOrg(
			ctx, owner.name, orgOpts)
		owner.addRepos(githubRepos)
		if _, ok := err.(*github.RateLimitError); ok {
			log.Printf("hit rate limit retreiving %s, continuing with partial audit\n",
				owner.name)
		} else if err != nil {
			return fmt.Errorf("failed obtaining %s repos from githuib api, bad request", owner.name)
		} else if resp.NextPage == 0 {
			break
		}
		orgOpts.Page = resp.NextPage
	}
	return nil
}

// fetchUserRepos used by fetchRepos is responsible for parsing github's user repo response. If no
// github token is available then fetchUserRepos might run into a rate limit in which case owner will
// log an error and gitleaks will exit. The rate limit for no token is 50 req/hour... not much.
// sorry for the redundancy
func (owner *Owner) fetchUserRepos(ctx context.Context, userOpts *github.RepositoryListOptions,
	gitClient *github.Client) error {
	var (
		githubRepos []*github.Repository
		resp        *github.Response
		err         error
	)
	for {
		githubRepos, resp, err = gitClient.Repositories.List(
			ctx, owner.name, userOpts)
		owner.addRepos(githubRepos)
		if _, ok := err.(*github.RateLimitError); ok {
			log.Printf("hit rate limit retreiving %s, continuing with partial audit\n",
				owner.name)
			break
		} else if err != nil {
			return fmt.Errorf("failed obtaining %s repos from github api, bad request", owner.name)
		} else if resp.NextPage == 0 {
			break
		}
		userOpts.Page = resp.NextPage
	}
	return nil
}

// addRepos used by fetchUserRepos and fetchOrgRepos appends new repos from
// github's org/user response.
func (owner *Owner) addRepos(githubRepos []*github.Repository) {
	for _, repo := range githubRepos {
		owner.repos = append(owner.repos, *newRepo(*repo.Name, *repo.CloneURL, owner.path+"/"+*repo.Name))
	}
}

// auditRepos is responsible for auditing all the owner's
// repos. auditRepos is used by main and will return the following exit codes
// 0: The audit succeeded with no findings
// 1: The audit failed, or wasn't attempted due to an execution failure.
// 2: The audit succeeded, and secrets / patterns were found.
func (owner *Owner) auditRepos() int {
	exitCode := ExitClean
	for _, repo := range owner.repos {
		leaksPst, err := repo.audit()
		if err != nil {
			failF("%v", err)
		}
		if leaksPst {
			exitCode = ExitLeaks
		}
	}
	if opts.Tmp {
		owner.rmTmp()
	}
	return exitCode
}

// failF prints a failure message out to stderr
// and exits with a exit code 2
func (owner *Owner) failF(format string, args ...interface{}) {
	fmt.Fprintf(os.Stderr, format, args...)
	os.Exit(ExitFailure)
}

// rmTmp removes the owner's temporary repo. rmTmp will only get called if temporary
// mode is set. rmTmp is called on a SIGINT and after the audits have finished
func (owner *Owner) rmTmp() {
	log.Printf("removing tmp gitleaks repo for %s\n", owner.path)
	os.RemoveAll(owner.path)
}

// ownerType returns the owner type extracted from opts.
// If no owner type is provided, gitleaks assumes the owner is ambiguous
// and the user is running gitleaks on a single repo
func ownerType() string {
	if opts.OrgMode {
		return "org"
	} else if opts.UserMode {
		return "user"
	}
	return ""
}

// ownerName returns the owner name extracted from the urls provided in opts.
// If no RepoURL, OrgURL, or UserURL is provided, then owner will log an error
// and gitleaks will exit.
func ownerName() string {
	if opts.RepoMode {
		splitSlashes := strings.Split(opts.URL, "/")
		return splitSlashes[len(splitSlashes)-2]
	} else if opts.UserMode || opts.OrgMode {
		_, ownerName := path.Split(opts.URL)
		return ownerName
	}
	// local repo
	return ""
}

// githubTokenClient creates an oauth client from your github access token.
// Gitleaks will attempt to retrieve your github access token from a cli argument
// or an env var - "GITHUB_TOKEN".
func githubTokenClient() *http.Client {
	var token string
	if opts.Token != "" {
		token = opts.Token
	} else {
		token = os.Getenv("GITHUB_TOKEN")
	}
	if token == "" {
		return nil
	}

	tokenService := oauth2.StaticTokenSource(
		&oauth2.Token{AccessToken: token},
	)
	tokenClient := oauth2.NewClient(context.Background(), tokenService)
	return tokenClient
}
