package testutils

import (
	"bytes"
	"errors"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/termie/go-shutil"
)

// Chdir changes current directory to dir.
// The previous current directory is restored when the test ends.
func Chdir(t *testing.T, dir string) {
	t.Helper()

	orig, err := os.Getwd()
	if err != nil {
		t.Fatalf("Setup: Can’t get current directory: %v", err)
	}
	if err := os.Chdir(dir); err != nil {
		t.Fatalf("Setup: Can’t change current directory: %v", err)
	}
	t.Cleanup(func() {
		if err := os.Chdir(orig); err != nil {
			t.Fatalf("Teardown: Can’t restore current directory: %v", err)
		}
	})
}

// CreatePath creates a directory or a file with dummy content based on whether
// the given path contains a slash character at the end.
func CreatePath(t *testing.T, path string) {
	t.Helper()

	if strings.HasSuffix(path, "/") {
		err := os.MkdirAll(path, 0750)
		require.NoError(t, err, "Setup: cannot create directory %s", path)
		return
	}

	err := os.MkdirAll(filepath.Dir(path), 0750)
	require.NoError(t, err, "Setup: can't create directory for file")

	err = os.WriteFile(path, []byte("new content"), 0600)
	require.NoError(t, err, "Setup: could not write sample file")
}

// NormalizeGoldenName returns the name of the golden file with illegal Windows
// characters replaced or removed.
func NormalizeGoldenName(t *testing.T, name string) string {
	t.Helper()

	name = strings.ReplaceAll(name, `\`, "_")
	name = strings.ReplaceAll(name, ":", "")
	return name
}

// Copy copies files and directories to dest.
func Copy(t *testing.T, src, dest string) {
	t.Helper()

	// check that src is a file
	info, err := os.Stat(src)
	require.NoError(t, err, "Cannot stat %s", src)
	if !info.IsDir() {
		require.NoError(t, shutil.CopyFile(src, dest, false), "Cannot copy %s to %s", src, dest)
		return
	}

	// Copy a directory
	require.NoError(t,
		shutil.CopyTree(
			src,
			dest,
			&shutil.CopyTreeOptions{Symlinks: true, CopyFunction: shutil.Copy}),
		"Cannot copy %s to %s", src, dest)
}

// MakeReadOnly makes dest read only and restore permission on cleanup.
func MakeReadOnly(t *testing.T, dest string) {
	t.Helper()

	// Get current dest permissions
	fi, err := os.Stat(dest)
	require.NoError(t, err, "Cannot stat %s", dest)
	mode := fi.Mode()

	err = os.Chmod(dest, 0444)
	require.NoError(t, err)

	t.Cleanup(func() {
		err := os.Chmod(dest, mode)
		require.NoError(t, err)
	})
}

const fileForEmptyDir = ".empty"

// CompareTreesWithFiltering allows comparing a goldPath directory to p. Those can be updated via the dedicated flag.
// It will filter dconf database and not commit it in the new golden directory.
func CompareTreesWithFiltering(t *testing.T, p, goldPath string, update bool) {
	t.Helper()

	// Update golden file
	if update {
		t.Logf("updating golden file %s", goldPath)
		require.NoError(t, os.RemoveAll(goldPath), "Cannot remove target golden directory")

		// check the source directory exists before trying to copy it
		info, err := os.Stat(p)
		if errors.Is(err, fs.ErrNotExist) {
			return
		}
		require.NoErrorf(t, err, "Error on checking %q", p)

		if !info.IsDir() {
			// copy file
			data, err := os.ReadFile(p)
			require.NoError(t, err, "Cannot read new generated file file %s", p)
			require.NoError(t, os.WriteFile(goldPath, data, info.Mode()), "Cannot write golden file")
		} else {
			// Filter dconf generated DB files that are machine dependent
			require.NoError(t,
				shutil.CopyTree(
					p, goldPath,
					&shutil.CopyTreeOptions{Symlinks: true, Ignore: ignoreDconfDB, CopyFunction: shutil.Copy}),
				"Can’t update golden directory")
			require.NoError(t, addEmptyMarker(goldPath), "Cannot create empty file in empty directories")
		}
	}

	var err error
	var gotContent map[string]string
	if _, err := os.Stat(p); err == nil {
		gotContent, err = treeContent(t, p, []byte("GVariant"))
		if err != nil {
			t.Fatalf("No generated content: %v", err)
		}
	}

	var goldContent map[string]string
	if _, err := os.Stat(goldPath); err == nil {
		goldContent, err = treeContent(t, goldPath, nil)
		if err != nil {
			t.Fatalf("No golden directory found: %v", err)
		}
	}
	assert.Equal(t, goldContent, gotContent, "got and expected content differs")

	// No more verification on p if it doesn’t exists
	if _, err := os.Stat(p); errors.Is(err, fs.ErrNotExist) {
		return
	}

	// Verify that each <DB>.d has a corresponding gvariant db generated by dconf update
	// search for dconfDir
	dconfDir := p
	err = filepath.WalkDir(dconfDir, func(p string, info fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		if !info.IsDir() {
			return nil
		}
		if info.Name() == "db" {
			dconfDir = filepath.Dir(p)
		}
		return nil
	})
	require.NoError(t, err, "can't find dconf directory")

	dbs, err := filepath.Glob(filepath.Join(dconfDir, "db", "*.d"))
	require.NoError(t, err, "Checking pattern for dconf db failed")
	for _, db := range dbs {
		_, err = os.Stat(strings.TrimSuffix(db, ".db"))
		assert.NoError(t, err, "Binary version of dconf DB should exists")
	}
}

// addEmptyMarker adds to any empty directory, fileForEmptyDir to it.
// That allows git to commit it.
func addEmptyMarker(p string) error {
	err := filepath.WalkDir(p, func(path string, de fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		if !de.IsDir() {
			return nil
		}

		entries, err := os.ReadDir(path)
		if err != nil {
			return err
		}
		if len(entries) == 0 {
			f, err := os.Create(filepath.Join(path, fileForEmptyDir))
			if err != nil {
				return err
			}
			f.Close()
		}
		return nil
	})

	return err
}

// treeContent builds a recursive file list of dir with their content
// It can ignore files starting with ignoreHeaders.
func treeContent(t *testing.T, dir string, ignoreHeaders []byte) (map[string]string, error) {
	t.Helper()

	r := make(map[string]string)

	err := filepath.WalkDir(dir, func(path string, de fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		// Ignore markers for empty directories
		if filepath.Base(path) == fileForEmptyDir {
			return nil
		}

		content := ""
		if !de.IsDir() {
			d, err := os.ReadFile(path)
			if err != nil {
				return err
			}
			// ignore given header
			if ignoreHeaders != nil && bytes.HasPrefix(d, ignoreHeaders) {
				return nil
			}
			content = string(d)
		}
		r[strings.TrimPrefix(path, dir)] = content
		return nil
	})
	if err != nil {
		return nil, err
	}

	return r, nil
}

// ignoreDconfDB is a utility function that returns the list of binary dconf db files to ignore during copy with shutils.CopyTree.
func ignoreDconfDB(src string, entries []os.FileInfo) []string {
	var r []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		d, err := os.ReadFile(filepath.Join(src, e.Name()))
		if err != nil {
			continue
		}

		if bytes.HasPrefix(d, []byte("GVariant")) {
			r = append(r, e.Name())
		}
	}
	return r
}
