// Package config collects together all configuration settings
// NOTE: Subject to change, do not rely on this package from outside git-lfs source
package config

import (
	"fmt"
	"reflect"
	"regexp"
	"strconv"
	"strings"
	"sync"

	"path/filepath"

	"github.com/git-lfs/git-lfs/errors"
	"github.com/git-lfs/git-lfs/tools"
)

var (
	Config                 = New()
	ShowConfigWarnings     = false
	defaultRemote          = "origin"
	gitConfigWarningPrefix = "lfs."
)

// FetchPruneConfig collects together the config options that control fetching and pruning
type FetchPruneConfig struct {
	// The number of days prior to current date for which (local) refs other than HEAD
	// will be fetched with --recent (default 7, 0 = only fetch HEAD)
	FetchRecentRefsDays int `git:"lfs.fetchrecentrefsdays"`
	// Makes the FetchRecentRefsDays option apply to remote refs from fetch source as well (default true)
	FetchRecentRefsIncludeRemotes bool `git:"lfs.fetchrecentremoterefs"`
	// number of days prior to latest commit on a ref that we'll fetch previous
	// LFS changes too (default 0 = only fetch at ref)
	FetchRecentCommitsDays int `git:"lfs.fetchrecentcommitsdays"`
	// Whether to always fetch recent even without --recent
	FetchRecentAlways bool `git:"lfs.fetchrecentalways"`
	// Number of days added to FetchRecent*; data outside combined window will be
	// deleted when prune is run. (default 3)
	PruneOffsetDays int `git:"lfs.pruneoffsetdays"`
	// Always verify with remote before pruning
	PruneVerifyRemoteAlways bool `git:"lfs.pruneverifyremotealways"`
	// Name of remote to check for unpushed and verify checks
	PruneRemoteName string `git:"lfs.pruneremotetocheck"`
}

// Storage configuration
type StorageConfig struct {
	LfsStorageDir string `git:"lfs.storage"`
}

type Configuration struct {
	// Os provides a `*Environment` used to access to the system's
	// environment through os.Getenv. It is the point of entry for all
	// system environment configuration.
	Os Environment

	// Git provides a `*Environment` used to access to the various levels of
	// `.gitconfig`'s. It is the point of entry for all Git environment
	// configuration.
	Git Environment

	CurrentRemote string

	loading    sync.Mutex // guards initialization of gitConfig and remotes
	remotes    []string
	extensions map[string]Extension
}

func New() *Configuration {
	c := &Configuration{Os: EnvironmentOf(NewOsFetcher())}
	c.Git = &gitEnvironment{config: c}
	initConfig(c)
	return c
}

// Values is a convenience type used to call the NewFromValues function. It
// specifies `Git` and `Env` maps to use as mock values, instead of calling out
// to real `.gitconfig`s and the `os.Getenv` function.
type Values struct {
	// Git and Os are the stand-in maps used to provide values for their
	// respective environments.
	Git, Os map[string][]string
}

// NewFrom returns a new `*config.Configuration` that reads both its Git
// and Enviornment-level values from the ones provided instead of the actual
// `.gitconfig` file or `os.Getenv`, respectively.
//
// This method should only be used during testing.
func NewFrom(v Values) *Configuration {
	c := &Configuration{
		Os:  EnvironmentOf(mapFetcher(v.Os)),
		Git: EnvironmentOf(mapFetcher(v.Git)),
	}
	initConfig(c)
	return c
}

func initConfig(c *Configuration) {
	c.CurrentRemote = defaultRemote
}

// Unmarshal unmarshals the *Configuration in context into all of `v`'s fields,
// according to the following rules:
//
// Values are marshaled according to the given key and environment, as follows:
//	type T struct {
//		Field string `git:"key"`
//		Other string `os:"key"`
//	}
//
// If an unknown environment is given, an error will be returned. If there is no
// method supporting conversion into a field's type, an error will be returned.
// If no value is associated with the given key and environment, the field will
// // only be modified if there is a config value present matching the given
// key. If the field is already set to a non-zero value of that field's type,
// then it will be left alone.
//
// Otherwise, the field will be set to the value of calling the
// appropriately-typed method on the specified environment.
func (c *Configuration) Unmarshal(v interface{}) error {
	into := reflect.ValueOf(v)
	if into.Kind() != reflect.Ptr {
		return fmt.Errorf("lfs/config: unable to parse non-pointer type of %T", v)
	}
	into = into.Elem()

	for i := 0; i < into.Type().NumField(); i++ {
		field := into.Field(i)
		sfield := into.Type().Field(i)

		lookups, err := c.parseTag(sfield.Tag)
		if err != nil {
			return err
		}

		var val interface{}
		for _, lookup := range lookups {
			if _, ok := lookup.Get(); !ok {
				continue
			}

			switch sfield.Type.Kind() {
			case reflect.String:
				val, _ = lookup.Get()
			case reflect.Int:
				val = lookup.Int(int(field.Int()))
			case reflect.Bool:
				val = lookup.Bool(field.Bool())
			default:
				return fmt.Errorf("lfs/config: unsupported target type for field %q: %v",
					sfield.Name, sfield.Type.String())
			}

			if val != nil {
				break
			}
		}

		if val != nil {
			into.Field(i).Set(reflect.ValueOf(val))
		}
	}

	return nil
}

var (
	tagRe    = regexp.MustCompile("((\\w+:\"[^\"]*\")\\b?)+")
	emptyEnv = EnvironmentOf(MapFetcher(nil))
)

type lookup struct {
	key string
	env Environment
}

func (l *lookup) Get() (interface{}, bool) { return l.env.Get(l.key) }
func (l *lookup) Int(or int) int           { return l.env.Int(l.key, or) }
func (l *lookup) Bool(or bool) bool        { return l.env.Bool(l.key, or) }

// parseTag returns the key, environment, and optional error assosciated with a
// given tag. It will return the XOR of either the `git` or `os` tag. That is to
// say, a field tagged with EITHER `git` OR `os` is valid, but pone tagged with
// both is not.
//
// If neither field was found, then a nil environment will be returned.
func (c *Configuration) parseTag(tag reflect.StructTag) ([]*lookup, error) {
	var lookups []*lookup

	parts := tagRe.FindAllString(string(tag), -1)
	for _, part := range parts {
		sep := strings.SplitN(part, ":", 2)
		if len(sep) != 2 {
			return nil, errors.Errorf("config: invalid struct tag %q", tag)
		}

		var env Environment
		switch strings.ToLower(sep[0]) {
		case "git":
			env = c.Git
		case "os":
			env = c.Os
		default:
			// ignore other struct tags, like `json:""`, etc.
			env = emptyEnv
		}

		uq, err := strconv.Unquote(sep[1])
		if err != nil {
			return nil, err
		}

		lookups = append(lookups, &lookup{
			key: uq,
			env: env,
		})
	}

	return lookups, nil
}

// BasicTransfersOnly returns whether to only allow "basic" HTTP transfers.
// Default is false, including if the lfs.basictransfersonly is invalid
func (c *Configuration) BasicTransfersOnly() bool {
	return c.Git.Bool("lfs.basictransfersonly", false)
}

// TusTransfersAllowed returns whether to only use "tus.io" HTTP transfers.
// Default is false, including if the lfs.tustransfers is invalid
func (c *Configuration) TusTransfersAllowed() bool {
	return c.Git.Bool("lfs.tustransfers", false)
}

func (c *Configuration) FetchIncludePaths() []string {
	patterns, _ := c.Git.Get("lfs.fetchinclude")
	return tools.CleanPaths(patterns, ",")
}

func (c *Configuration) FetchExcludePaths() []string {
	patterns, _ := c.Git.Get("lfs.fetchexclude")
	return tools.CleanPaths(patterns, ",")
}

func (c *Configuration) Remotes() []string {
	c.loadGitConfig()

	return c.remotes
}

func (c *Configuration) Extensions() map[string]Extension {
	c.loadGitConfig()
	return c.extensions
}

// SortedExtensions gets the list of extensions ordered by Priority
func (c *Configuration) SortedExtensions() ([]Extension, error) {
	return SortExtensions(c.Extensions())
}

func (c *Configuration) FetchPruneConfig() FetchPruneConfig {
	f := &FetchPruneConfig{
		FetchRecentRefsDays:           7,
		FetchRecentRefsIncludeRemotes: true,
		PruneOffsetDays:               3,
		PruneRemoteName:               "origin",
	}

	if err := c.Unmarshal(f); err != nil {
		panic(err.Error())
	}
	return *f
}

func (c *Configuration) StorageConfig() StorageConfig {
	s := &StorageConfig{
		LfsStorageDir: "lfs",
	}

	if err := c.Unmarshal(s); err != nil {
		panic(err.Error())
	}
	if !filepath.IsAbs(s.LfsStorageDir) {
		s.LfsStorageDir = filepath.Join(LocalGitStorageDir, s.LfsStorageDir)
	}
	return *s
}

func (c *Configuration) SkipDownloadErrors() bool {
	return c.Os.Bool("GIT_LFS_SKIP_DOWNLOAD_ERRORS", false) || c.Git.Bool("lfs.skipdownloaderrors", false)
}

func (c *Configuration) SetLockableFilesReadOnly() bool {
	return c.Os.Bool("GIT_LFS_SET_LOCKABLE_READONLY", true) && c.Git.Bool("lfs.setlockablereadonly", true)
}

// loadGitConfig is a temporary measure to support legacy behavior dependent on
// accessing properties set by ReadGitConfig, namely:
//  - `c.extensions`
//  - `c.uniqRemotes`
//  - `c.gitConfig`
//
// Since the *gitEnvironment is responsible for setting these values on the
// (*config.Configuration) instance, we must call that method, if it exists.
//
// loadGitConfig returns a bool returning whether or not `loadGitConfig` was
// called AND the method did not return early.
func (c *Configuration) loadGitConfig() bool {
	if g, ok := c.Git.(*gitEnvironment); ok {
		return g.loadGitConfig()
	}

	return false
}

// CurrentCommitter returns the name/email that would be used to author a commit
// with this configuration. In particular, the "user.name" and "user.email"
// configuration values are used
func (c *Configuration) CurrentCommitter() (name, email string) {
	name, _ = c.Git.Get("user.name")
	email, _ = c.Git.Get("user.email")
	return
}
