package gitconfig

import (
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-config.html#EXAMPLES
var configSampleDocs = `#
# This is the config file, and
# a '#' or ';' character indicates
# a comment
#

; core variables
[core]
	; Don't trust file modes
	filemode = false

; Our diff algorithm
[diff]
	external = /usr/local/bin/diff-wrapper
	renames = true

; Proxy settings
[core]
	gitproxy = default-proxy ; default proxy

; HTTP
[http]
	sslVerify

[http "https://weak.example.com"]
	sslVerify = false
	cookieFile = /tmp/cookie.txt
`

var configSampleComplex = `
[alias]

   # add
   a = add                                   # add
   aa = add --all
   all = add -A
   chunkyadd = add --patch    # stage commits chunk by chunk

   # branch
   b  = branch -v                            # branch (verbose)
   branches = branch -a
   recent = branch --sort=-committerdate

   # commit
   c = commit -m
   ca = commit -am
   ci = commit
   credit = "!f() { git commit --amend --author \"$1 <$2>\" -C HEAD; }; f" # Credit an author on the latest commit
   credit = commit --amend --author "$1 <$2>" -C HEAD
   amend = commit --amend
   commend = commit --amend --no-edit

   # checkout
   co = checkout                             # checkout
   nb = checkout -b                          # create and switch to a new branch (mnemonic: "git new branch branchname...")
   go = checkout -B                          # Switch to a branch, creating it if necessary

   # clone
   cr = clone --recursive                     # Clone a repository including all submodules

   # cherry-pick
   cp = cherry-pick -x               # grab a change from a branch

   # diff
   d = !"git diff-index --quiet HEAD -- || clear; git diff --patch-with-stat" # Show the diff between the latest commit and the current state
   di = diff
   dc = diff --cached
   div = divergence                        # Divergence (commits we added and commits remote added)
   ds = diff --stat=160,120
   gn = goodness                             # Goodness (summary of diff lines added/removed/total)
   gnc   = goodness --cached
   last = diff HEAD^

   # log
   l = log --pretty=oneline -n 20 --graph    # View the SHA, description, and history graph of the latest 20 commits
   changes = log --pretty=format:\"%h %cr %cn %Cgreen%s%Creset\" --name-status
   short = log --pretty=format:\"%h %cr %cn %Cgreen%s%Creset\"
   changelog = log --pretty=format:\" * %s\"
   shortnocolor = log --pretty=format:\"%h %cr %cn %s\"
   show-graph = log --graph --abbrev-commit --pretty=oneline

   # pull
   please = push --force-with-lease
   pl = pull
   fa = fetch --all
   ff = merge --ff-only
   noff  = merge --no-ff
   pullff   = pull --ff-only
   p = !"git pull; git submodule foreach git pull origin master"  # Pull in remote changes for the current repository and all its submodules
   pp = !"git pull ; git push origin master"
   ppd = !"git pull origin develop ; git push origin develop"
   mdm = !"git checkout master ; git pull origin master ; git push origin master ; git merge develop ; git push origin master ; git checkout develop"

   # push
   ps = push
   pom   = pull origin master
   pum = push origin master

   # rebase
   rc = rebase --continue # continue rebase
   rs = rebase --skip # skip rebase
   reb = "!r() { git rebase -i HEAD~$1; }; r"   # Interactive rebase with the given number of latest commits

   # remote
   r = remote -v
   remotes = remote -v

   # reset
   unstage = reset HEAD # remove files from index (tracking)
   uncommit = reset --soft HEAD^ # go back before last commit, with files in uncommitted state
   undo = reset --soft HEAD^
   filelog = log -u # show changes to a file
   mt = mergetool # fire up the merge tool

   # stash
   ss = stash # stash changes
   sl = stash list # list stashes
   sa = stash apply # apply stash (restore changes)
   sd = stash drop # drop stashes (destroy changes)
   stsh = stash --keep-index
   staash = stash --include-untracked
   staaash = stahs --all

   # status
   s = status -s                             # View the current working tree status using the short format
   st = status # status
   stat = status # status
   shorty = status --short --branch

   # tag
   t = tag -n                                # show tags with <n> lines of each tag message
   tags = tag -l                             # Show verbose output about tags, branches or remotes

  # init
  it = !"git init && git commit -m "root" --allow-empty"

  # merge
  merc = merge --no-ff
	grog = log --graph --abbrev-commit --decorate --all --format=format:\"%C(bold blue)%h%C(reset) - %C(bold cyan)%aD%C(dim white) - %an%C(reset) %C(bold green)(%ar)%C(reset)%C(bold yellow)%d%C(reset)%n %C(white)%s%C(reset)\"

[apply]
   # Detect whitespace errors when applying a patch
   whitespace = fix

[branch]
   autosetupmerge = true

[core]
  editor = vim
  excludesfile = ~/.gitignore
  attributesfile = ~/.gitattributes
  # Treat spaces before tabs, lines that are indented with 8 or more spaces, and all kinds of trailing whitespace as an error
  whitespace = space-before-tab,indent-with-non-tab,trailing-space
  autocrlf = input
  protectHFS = true
  protectNTFS = true
  sshCommand = ssh -oControlMaster=auto -oControlPersist=600 -oControlPath=/tmp/.ssh-%C

[receive]
  fsckObjects = true
  quotepath = false

[color]
   # Use colors in Git commands that are capable of colored output when outputting to the terminal
   ui = auto

[diff]

[format]
   pretty = format:%C(blue)%ad%Creset %C(yellow)%h%C(green)%d%Creset %C(blue)%s %C(magenta) [%an]%Creset

[mergetool]
   prompt = false

#[mergetool "mvimdiff"]
#  cmd="mvim -c 'Gdiff' $MERGED" # use fugitive.vim for 3-way merge
#  keepbackup=false

[merge]
   # Include summaries of merged commits in newly created merge commit messages
   log = true
   summary = true
   verbosity = 1
#   tool = mvimdiff

# Use origin as the default remote on the master branch in all cases
[branch "master \""]
   remote = origin
   merge = refs/heads/master

[github]
   user = johndoe

# URL shorthands
[url "git@github.com:"]
   insteadOf = "gh:"
   pushInsteadOf = "github:"
   pushInsteadOf = "git://github.com/"
	 insteadOf = https://github.com

[url "git://github.com/"]
   insteadOf = "github:"

[url "git@gist.github.com:"]
   insteadOf = "gst:"
   pushInsteadOf = "gist:"
   pushInsteadOf = "git://gist.github.com/"

[url "git://gist.github.com/"]
   insteadOf = "gist:"

[url "git@gitlab.com:"]
	insteadOf = https://gitlab.com/

[url "git@gitlab.com:"]
	insteadOf = http://gitlab.com/

[user]
   email = john.doe@gmail.com
   name = John Doe
	 signingkey = DEADBEEF

[push]
	default = simple

[gc]
	auto = 64
	autopacklimit = 64
[pull]
	rebase = false
[init]
	defaultBranch = master
[fetch]
	prune = true

[credential]
	helper = osxkeychain

`

var configSampleGopass = `
# This is a gopass config file

[core]
  autoimport = true
  cliptimeout = 45
  editor = vim
  exportkeys = true
  pager = false
  notifications = true

[generate]
  autoclip = true

[show]
  safecontent = false

[mounts]
  path = /home/johndoe/.password-store

[mounts "foo/sub"]
  path = /home/johndoe/.password-store-foo-sub

[mounts "work"]
  path = /home/johndoe/.password-store-work

[domain-alias "foo.com"]
  insteadOf = foo.de

[domain-alias "foo.com"]
  insteadOf = foo.it
`

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

	c := &Configs{
		global: ParseConfig(strings.NewReader(configSampleGopass)),
	}
	c.global.noWrites = true

	assert.Equal(t, "true", c.Get("generate.autoclip"))
	assert.Equal(t, "true", c.Get("core.autoimport"))
	assert.Equal(t, "45", c.Get("core.cliptimeout"))
	assert.Equal(t, "vim", c.Get("core.editor"))
	assert.Equal(t, "true", c.Get("core.exportkeys"))
	assert.Equal(t, "false", c.Get("core.pager"))
	assert.Equal(t, "true", c.Get("core.notifications"))
	assert.Equal(t, "false", c.Get("show.safecontent"))
	assert.Equal(t, []string{"foo.de", "foo.it"}, c.GetAll("domain-alias.foo.com.insteadOf"))

	assert.Equal(t, "/home/johndoe/.password-store", c.Get("mounts.path"))
	assert.Equal(t, "/home/johndoe/.password-store-foo-sub", c.Get("mounts.foo/sub.path"))
	assert.Equal(t, "/home/johndoe/.password-store-work", c.Get("mounts.work.path"))

	t.Logf("Raw:\n%s\n", c.global.raw.String())
	t.Logf("Vars:\n%+v\n", c.global.vars)
}

func TestGopassReadWrite(t *testing.T) {
	td := t.TempDir()
	sysCfg := filepath.Join(td, "system", "config")
	require.NoError(t, os.MkdirAll(filepath.Dir(sysCfg), 0o700))
	require.NoError(t, os.WriteFile(sysCfg, []byte(`[core]
	autoimport = true
	editor = vim
	cliptimeout = 45
	exportkeys = true
	pager = false
	notifications = true
	`), 0o600))

	globalCfg := filepath.Join(td, "global", "config")
	require.NoError(t, os.MkdirAll(filepath.Dir(globalCfg), 0o700))
	require.NoError(t, os.WriteFile(globalCfg, []byte(`[generate]
	autoclip = true
	pager = false
	[show]
	safecontent = false
	[mounts]
	path = /home/johndoe/.password-store
	[mounts "foo/sub"]
	path = /home/johndoe/.password-store-foo-sub
	[mounts "work"]
	path = /home/johndoe/.password-store-work
	[domain-alias "foo.com"]
	insteadOf = foo.de
	[domain-alias "foo.com"]
	insteadOf = foo.it
`), 0o600))

	t.Setenv("GOPASS_HOMEDIR", td)

	c := New()
	c.SystemConfig = sysCfg
	c.GlobalConfig = filepath.Join("global", "config")
	c.global.path = globalCfg
	c.LoadAll(td)

	assert.Equal(t, "true", c.Get("generate.autoclip"))
	assert.Equal(t, "true", c.Get("core.autoimport"))
	assert.Equal(t, "45", c.Get("core.cliptimeout"))
	assert.Equal(t, "vim", c.Get("core.editor"))
	assert.Equal(t, "true", c.Get("core.exportkeys"))
	assert.Equal(t, "false", c.Get("core.pager"))
	assert.Equal(t, "true", c.Get("core.notifications"))
	assert.Equal(t, "false", c.Get("show.safecontent"))
	assert.Equal(t, []string{"foo.de", "foo.it"}, c.GetAll("domain-alias.foo.com.insteadOf"))

	assert.Equal(t, "/home/johndoe/.password-store", c.Get("mounts.path"))
	assert.Equal(t, "/home/johndoe/.password-store-foo-sub", c.Get("mounts.foo/sub.path"))
	assert.Equal(t, "/home/johndoe/.password-store-work", c.Get("mounts.work.path"))

	t.Logf("Raw:\n%s\n", c.global.raw.String())
	t.Logf("Vars:\n%+v\n", c.global.vars)
}

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

	c := ParseConfig(strings.NewReader(configSampleDocs))

	for k, v := range c.vars {
		t.Logf("%s => %s\n", k, v)
	}

	want := map[string]string{
		"core.filemode": "false",
		"diff.external": "/usr/local/bin/diff-wrapper",
		"diff.renames":  "true",
		"core.gitproxy": "default-proxy",
		// TODO(gitconfig): "http.sslVerify": "", // not supported, yet
		"http.https://weak.example.com.sslVerify":  "false",
		"http.https://weak.example.com.cookieFile": "/tmp/cookie.txt",
	}

	for k, w := range want {
		v, ok := c.Get(k)
		assert.True(t, ok)
		assert.Equal(t, w, v)
	}
}

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

	c := ParseConfig(strings.NewReader(configSampleComplex))

	// variable names are case-insensitive
	_, ok := c.vars["core.sshcommand"]
	assert.True(t, ok)

	v, ok := c.Get("core.sshCommand")
	assert.True(t, ok)
	assert.Equal(t, "ssh -oControlMaster=auto -oControlPersist=600 -oControlPath=/tmp/.ssh-%C", v)
}

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

	c := ParseConfig(strings.NewReader(configSampleComplex)) //nolint:staticcheck

	v, ok := c.Get("core.sshCommand")
	assert.True(t, ok)
	assert.Equal(t, "ssh -oControlMaster=auto -oControlPersist=600 -oControlPath=/tmp/.ssh-%C", v)
}

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

	c := ParseConfig(strings.NewReader(configSampleDocs))
	c.noWrites = true
	require.NoError(t, c.Set("core.gitproxy", "foobar"))
	want := strings.ReplaceAll(configSampleDocs, "default-proxy", "foobar")
	assert.Equal(t, want, c.raw.String())
}

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

	c := ParseConfig(strings.NewReader(configSampleDocs))
	c.noWrites = true
	require.NoError(t, c.Unset("core.filemode"))
	want := `#
# This is the config file, and
# a '#' or ';' character indicates
# a comment
#

; core variables
[core]
	; Don't trust file modes

; Our diff algorithm
[diff]
	external = /usr/local/bin/diff-wrapper
	renames = true

; Proxy settings
[core]
	gitproxy = default-proxy ; default proxy

; HTTP
[http]
	sslVerify

[http "https://weak.example.com"]
	sslVerify = false
	cookieFile = /tmp/cookie.txt
`
	assert.Equal(t, want, c.raw.String())
}

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

	td := t.TempDir()
	c := &Config{
		path:     filepath.Join(td, "config"),
		noWrites: false,
	}
	require.Error(t, c.Set("foobar", "baz"))
	require.NoError(t, c.Set("foo.bar", "baz"))

	v, ok := c.Get("foo.bar")
	assert.True(t, ok)
	assert.Equal(t, "baz", v)

	buf, err := os.ReadFile(c.path)
	require.NoError(t, err)
	assert.Equal(t, "[foo]\n\tbar = baz\n", string(buf))
}

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

	c := &Configs{
		global: ParseConfig(strings.NewReader(configSampleGopass)),
	}
	c.global.noWrites = true
	assert.Equal(t, []string{
		"mounts.foo/sub.path",
		"mounts.path",
		"mounts.work.path",
	}, c.List("mounts."))
}

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

	c := &Configs{global: ParseConfig(strings.NewReader(configSampleGopass))}
	c.global.noWrites = true
	assert.Equal(t, []string{"core", "domain-alias", "generate", "mounts", "show"}, c.ListSections())
}

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

	c := &Configs{global: ParseConfig(strings.NewReader(configSampleGopass))}
	c.global.noWrites = true
	assert.Equal(t, []string{"foo/sub", "work"}, c.ListSubsections("mounts"))
}

func TestGitBinary(t *testing.T) {
	t.Skip("not ready, yet") // TODO(gitconfig) make tests pass

	cfgs := New()
	cfgs.LoadAll(".")

	cmd := exec.Command("git", "config", "--list")
	buf, err := cmd.Output()
	require.NoError(t, err)
	lines := strings.Split(string(buf), "\n")
	for _, line := range lines {
		p := strings.SplitN(line, "=", 2)
		if len(p) < 2 {
			continue
		}
		key := p[0]
		want := p[1]

		assert.Equal(t, want, cfgs.Get(key), key)
	}
}

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

	t.Skip("not ready, yet") // TODO(gitconfig) make tests pass

	c := New()
	c.NoWrites = true
	c.LoadAll("./.git")

	cmd := exec.CommandContext(t.Context(), "git", "config", "--list")
	buf, err := cmd.Output()
	require.NoError(t, err)
	cliOut := strings.Split(string(buf), "\n")
	sort.Strings(cliOut)
	// filter invalid lines and duplicates
	cliOut = func(s []string) []string {
		prev := ""
		out := make([]string, 0, len(s))
		for _, l := range s {
			if !strings.Contains(l, "=") {
				continue
			}
			if l == prev {
				continue
			}
			out = append(out, l)
			prev = l
		}

		return out
	}(cliOut)

	libOut := c.KVList("", "=")
	// filter duplicates
	libOut = func(s []string) []string {
		prev := ""
		out := make([]string, 0, len(s))
		for _, l := range s {
			if l == prev {
				continue
			}
			out = append(out, l)
			prev = l
		}

		return out
	}(libOut)

	// TODO: Enable this asserting when we have burned down all diffs.
	// assert.Equal(t, cliOut, libOut)

	diffs := 0
	t.Logf("% 38s | % 38s | % 5s", "CLI", "LIB", "DIFF")
	for i := 0; i < len(cliOut) || i < len(libOut); i++ {
		left := ""
		right := ""

		if i < len(cliOut) {
			left = cliOut[i]
		}
		if i < len(libOut) {
			right = libOut[i]
		}
		left = strings.TrimSpace(left)
		right = strings.TrimSpace(right)

		// t.Logf("% 38s | % 38s | %t", left, right, left != right)
		if left != right {
			t.Logf("% 38s | % 38s | %t", left, right, left != right)
			diffs++
		}
	}
	assert.Zero(t, diffs, "There are %d differences between the CLI and the library output", diffs)
}
