package config

import (
	"bufio"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"testing"

	"github.com/gopasspw/gopass/pkg/set"
)

// ignoredEnvs is a list of environment variables that are used by gopass
// but originate from elsewhere. They should be well known and properly
// documented already.
var ignoredEnvs = set.Map([]string{
	// keep-sorted start
	"APPDATA",
	"GIT_AUTHOR_EMAIL",
	"GIT_AUTHOR_NAME",
	"GNUPGHOME",
	"GOPASS_CONFIG_NOSYSTEM", // name assembled, tests can't catch it
	"GOPASS_DEBUG_FILES",     // indirect usage
	"GOPASS_DEBUG_FUNCS",     // indirect usage
	"GOPASS_GPG_OPTS",        // indirect usage
	"GOPASS_UMASK",           // indirect usage
	"GOPATH",
	"GPG_TTY",
	"HOME",
	"LOCALAPPDATA",
	"PASSWORD_STORE_UMASK", // indirect usage
	"XDG_CACHE_HOME",
	"XDG_CONFIG_HOME",
	"XDG_DATA_HOME",
	"XDG_RUNTIME_DIR",
	// keep-sorted end
})

// ignoredOptions is a list of config options that are used by gopass
// but may not be covered easily by a regexp.
var ignoredOptions = set.Map([]string{
	// keep-sorted start
	"core.post-hook",
	"core.pre-hook",
	"include.path",
	"recipients.hash",
	"user.email",
	"user.name",
	// keep-sorted end
})

func TestConfigOptsInDocs(t *testing.T) {
	t.Parallel()

	documented := documentedOpts(t)
	used := usedOpts(t)

	t.Logf("Config options documented in doc: %+v", documented)
	t.Logf("Config options used in the code: %+v", used)

	for _, k := range set.SortedKeys(documented) {
		if _, got := migrationOpts[k]; got {
			continue
		}
		if !used[k] {
			t.Errorf("Documented but not used: %s", k)
		}
	}
	for _, k := range set.SortedKeys(used) {
		if _, got := migrationOpts[k]; got {
			t.Errorf("Legacy option still used: %s", k)
		}
		if !documented[k] {
			t.Errorf("Used but not documented: %s", k)
		}
	}
}

func usedOpts(t *testing.T) map[string]bool {
	t.Helper()

	optRE := regexp.MustCompile(`(?:\.Get(?:|Int|Bool|All|Global)\(\"([a-z]+\.[a-z-]+)\"\)|\.Get(?:|Int|Bool)M\([^,]+, \"([a-z]+\.[a-z-]+)\"\)|config\.(?:Bool|Int|String)\((?:ctx|c\.Context), \"([a-z]+\.[a-z-]+)\"\)|hook\.Invoke(?:Root)?\(ctx, \"([a-z]+\.[a-z-]+)\")`)
	opts := make(map[string]bool, 42)

	dir := filepath.Join("..", "..")
	if err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() && strings.HasPrefix(info.Name(), ".") && path != dir {
			return filepath.SkipDir
		}
		if info.IsDir() {
			return nil
		}
		if strings.HasSuffix(info.Name(), "_test.go") {
			return nil
		}
		if !strings.HasSuffix(info.Name(), ".go") {
			return nil
		}

		return usedOptsInFile(t, path, opts, optRE)
	}); err != nil {
		t.Errorf("failed to walk %s: %s", dir, err)
	}

	return opts
}

func usedOptsInFile(t *testing.T, fn string, opts map[string]bool, re *regexp.Regexp) error {
	t.Helper()

	fh, err := os.Open(fn)
	if err != nil {
		return err
	}
	defer fh.Close() //nolint:errcheck

	scanner := bufio.NewScanner(fh)
	for scanner.Scan() {
		line := scanner.Text()

		if !re.MatchString(line) {
			continue
		}

		found := re.FindStringSubmatch(line)
		// t.Logf("found: %q", found)
		if len(found) < 4 {
			continue
		}

		for i := 1; i < 10; i++ {
			if found[i] == "" {
				continue
			}
			if ignoredOptions[found[i]] {
				break
			}
			opts[found[i]] = true

			break
		}
	}

	return nil
}

func documentedOpts(t *testing.T) map[string]bool {
	t.Helper()

	fn := filepath.Join("..", "..", "docs", "config.md")
	fh, err := os.Open(fn)
	if err != nil {
		t.Fatalf("failed to open %s: %s", fn, err)
	}
	defer fh.Close() //nolint:errcheck

	optRE := regexp.MustCompile(`^\| .([a-z]+\.[a-z-]+).`)

	opts := make(map[string]bool, 42)
	scanner := bufio.NewScanner(fh)
	for scanner.Scan() {
		line := scanner.Text()

		if !optRE.MatchString(line) {
			continue
		}
		found := optRE.FindStringSubmatch(line)
		if len(found) < 2 {
			continue
		}
		if _, got := ignoredOptions[found[1]]; got {
			continue
		}
		opts[found[1]] = true
	}

	return opts
}

func TestEnvVarsInDocs(t *testing.T) {
	t.Parallel()

	documented := documentedEnvs(t)
	used := usedEnvs(t)

	t.Logf("env options documented in doc: %+v", documented)
	t.Logf("env options used in the code: %+v", used)

	for _, k := range set.SortedKeys(documented) {
		if !used[k] {
			t.Errorf("Documented but not used: %s", k)
		}
	}
	for _, k := range set.SortedKeys(used) {
		if !documented[k] {
			t.Errorf("Used but not documented: %s", k)
		}
	}
}

func usedEnvs(t *testing.T) map[string]bool {
	t.Helper()

	optRE := regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\"([^"]+)\"\)`)
	opts := make(map[string]bool, 42)

	dir := filepath.Join("..", "..")
	if err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() && strings.HasPrefix(info.Name(), ".") && path != dir {
			return filepath.SkipDir
		}
		if info.IsDir() && (info.Name() == "helpers" || info.Name() == "tests") {
			return filepath.SkipDir
		}
		if info.IsDir() {
			return nil
		}
		if strings.HasSuffix(info.Name(), "_test.go") {
			return nil
		}
		if !strings.HasSuffix(info.Name(), ".go") {
			return nil
		}

		return usedEnvsInFile(t, path, opts, optRE)
	}); err != nil {
		t.Errorf("failed to walk %s: %s", dir, err)
	}

	return opts
}

func usedEnvsInFile(t *testing.T, fn string, opts map[string]bool, re *regexp.Regexp) error {
	t.Helper()

	fh, err := os.Open(fn)
	if err != nil {
		return err
	}
	defer fh.Close() //nolint:errcheck

	scanner := bufio.NewScanner(fh)
	for scanner.Scan() {
		line := scanner.Text()

		if !re.MatchString(line) {
			continue
		}

		found := re.FindStringSubmatch(line)
		// t.Logf("found: %q", found)
		if len(found) < 2 {
			continue
		}

		v := found[1]

		if ignoredEnvs[v] {
			continue
		}

		opts[v] = true
	}

	return nil
}

func documentedEnvs(t *testing.T) map[string]bool {
	t.Helper()

	fn := filepath.Join("..", "..", "docs", "config.md")
	fh, err := os.Open(fn)
	if err != nil {
		t.Fatalf("failed to open %s: %s", fn, err)
	}
	defer fh.Close() //nolint:errcheck

	optRE := regexp.MustCompile(`^\| .([A-Z0-9_]+).`)

	opts := make(map[string]bool, 42)
	scanner := bufio.NewScanner(fh)
	for scanner.Scan() {
		line := scanner.Text()

		if !optRE.MatchString(line) {
			continue
		}
		found := optRE.FindStringSubmatch(line)
		if len(found) < 2 {
			continue
		}

		v := found[1]

		if ignoredEnvs[v] {
			continue
		}

		opts[v] = true
	}

	return opts
}
